Compare commits
687 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ac4f555f6 | |||
| 098544393e | |||
| 29165da5ac | |||
| 9343583c69 | |||
| d82d255bae | |||
| 93a7042a84 | |||
| 5be5c869da | |||
| 8d45e023b2 | |||
| f2ae1398db | |||
| c2736a61fb | |||
| 76fe8dbc69 | |||
| 64408c8d8b | |||
| db55bb4693 | |||
| 9c6856b584 | |||
| a4899144c5 | |||
| 808083c938 | |||
| 7e41ab4460 | |||
| 75a2bec8d5 | |||
| c35857bb61 | |||
| 2c897992c5 | |||
| 7d5cb574c6 | |||
| c582f96cf6 | |||
| 8fab3f60a7 | |||
| c6e981b3a1 | |||
| f0c5c5660a | |||
| 9c647bb31b | |||
| e1e82ac586 | |||
| 585d6da98d | |||
| bc279dd7fd | |||
| f2fdead6d3 | |||
| f66ccb4741 | |||
| 32c10c2b23 | |||
| 05674d9586 | |||
| 11bda9aae5 | |||
| 02c803385c | |||
| 8fe7a1e756 | |||
| 4a61ffea8d | |||
| 91548691ad | |||
| 36a646e5c0 | |||
| f306599ab2 | |||
| 3a7b777717 | |||
| 2334e659ad | |||
| 2a0216c87a | |||
| ab2d671760 | |||
| 5532d0a7d9 | |||
| 277e7719d3 | |||
| d6cb9fc261 | |||
| e6a857335f | |||
| e82e3a8343 | |||
| 6d812c76c2 | |||
| 5af4bb7ade | |||
| 030d66fd65 | |||
| c929f8d0a6 | |||
| 6fb50cfc67 | |||
| ebcdcf40dc | |||
| 76a05e717b | |||
| 062ce31cf7 | |||
| 98abaf6635 | |||
| 8675ab3215 | |||
| ad6ef2884a | |||
| 3ebb8a5e79 | |||
| 652b1b0821 | |||
| 4747119a7f | |||
| bfd769b349 | |||
| 40c3c73bfd | |||
| 96d11b1d7d | |||
| b3771f3488 | |||
| a07c125454 | |||
| 54a7b6b568 | |||
| 77d0ac4fce | |||
| bddd733466 | |||
| e6ffb08954 | |||
| 2fe8f659bc | |||
| ab26d84632 | |||
| a202ca4865 | |||
| a2db5bef25 | |||
| a81fa1ead7 | |||
| e7315cbc7e | |||
| cd757f177f | |||
| 103c55c072 | |||
| 765caab6df | |||
| 72f4663dd5 | |||
| deb6d92b55 | |||
| 0222ea6ccb | |||
| 8c047600a0 | |||
| 57b5877fdc | |||
| 7ddf67a977 | |||
| 7af2212d11 | |||
| 5e13651ed9 | |||
| 08e9c8d463 | |||
| b3d93880b5 | |||
| 05e100a492 | |||
| a4e22de455 | |||
| c89600591c | |||
| f1d57d89c7 | |||
| 83124875d3 | |||
| 9460e9faae | |||
| 882afd938b | |||
| ab72a10578 | |||
| d76d020cfe | |||
| e39756fa3f | |||
| 8e794e1ef1 | |||
| caf68c8137 | |||
| 5161ac8f77 | |||
| 4df96db809 | |||
| 5605930aef | |||
| 85bf3cfa84 | |||
| 8eec73d88c | |||
| b63dbbbfd5 | |||
| 8b16157047 | |||
| 6628682f97 | |||
| 5971ffc470 | |||
| baf95ec328 | |||
| 0a6590fafd | |||
| 22dd0ee0f6 | |||
| f9ad6046e8 | |||
| 8a21902fa1 | |||
| 016564eda7 | |||
| 5a8ff7db37 | |||
| cc08596adf | |||
| cdc5836785 | |||
| 813ed79073 | |||
| 537bab69ab | |||
| b0871ad94b | |||
| 0bd7574ab2 | |||
| c3f8b48bf7 | |||
| 90f731ac1e | |||
| e83fd66023 | |||
| d49bab403d | |||
| 8e6cbcbc2a | |||
| a6bef63aa7 | |||
| 898e28c40c | |||
| 9fda7ef596 | |||
| 17ba1713ad | |||
| f4110204b1 | |||
| d2a183b52d | |||
| a8dcf3113c | |||
| 1f52a6c9e0 | |||
| adbed63196 | |||
| 33e20845f1 | |||
| 9a7096c301 | |||
| 4c365032ff | |||
| bbd32d40a6 | |||
| 73f4a91fa1 | |||
| 1e2e383794 | |||
| 3b70b071e3 | |||
| 838c0ea421 | |||
| 3ac9ff1dd7 | |||
| 3e90b29d2b | |||
| b74186464b | |||
| f4934dcb28 | |||
| 30973a8e78 | |||
| 9b89625660 | |||
| c70ba5962e | |||
| 8c722b0a18 | |||
| 3ece6770e1 | |||
| b39ec41255 | |||
| 1407018d98 | |||
| d4d661d6d4 | |||
| 2092f078ec | |||
| 924569aefb | |||
| a5864e15f8 | |||
| 0dc89cf569 | |||
| 3c1e9d03a0 | |||
| 28a082f47a | |||
| 38994d5900 | |||
| 472896328a | |||
| 92f408035a | |||
| 979186243c | |||
| ee66247bea | |||
| 66a9daf733 | |||
| 69a9e0cb40 | |||
| cd6beaa7d4 | |||
| 5f4ff17630 | |||
| 3c3bbe516e | |||
| a1d1ab1f0f | |||
| ab9456fff8 | |||
| 2f673469aa | |||
| 05fde22075 | |||
| deab7b7dd6 | |||
| ae5da3b6e0 | |||
| 4d0c8f49aa | |||
| 3068f4e367 | |||
| 3844704490 | |||
| 12144b8220 | |||
| b639080494 | |||
| e67d7d68cb | |||
| b8f18c1cf5 | |||
| 529958c4af | |||
| 40077a577c | |||
| e0fbd706ce | |||
| b76879f204 | |||
| 564dd8bf95 | |||
| b317f7cd76 | |||
| a3b49d2642 | |||
| 6f20620c97 | |||
| b6a055a01a | |||
| 44ac593ddc | |||
| ca4c2a661e | |||
| 8b3b39f390 | |||
| 915934e5dd | |||
| 42f15018ae | |||
| 3554a7b5b9 | |||
| f2941939b7 | |||
| 1a77ded997 | |||
| 05d25d4d7c | |||
| 7cc1fef989 | |||
| abc599d7f9 | |||
| eefbb63299 | |||
| fdbb474763 | |||
| 6a7eef6956 | |||
| 9b27e86e0f | |||
| dbe8f5d814 | |||
| 9847594ca1 | |||
| 4a966e5e52 | |||
| d8ba4549aa | |||
| 986f5eafc8 | |||
| 84df64fcfe | |||
| a9150b85b9 | |||
| 68e6c8be35 | |||
| bd42655c0e | |||
| fe1c96ea12 | |||
| bae2bf63eb | |||
| 803e0dc5a3 | |||
| 474c37ec8e | |||
| eb7726263a | |||
| f87ccc51c5 | |||
| b0b4e7803c | |||
| 450f19c656 | |||
| 55b9c08f99 | |||
| a5f3aab775 | |||
| 7442c9b106 | |||
| ae66cb478b | |||
| 2516c3e618 | |||
| 02a5893279 | |||
| bd0d653210 | |||
| 62626ddc08 | |||
| b6574f0097 | |||
| c35a8dd803 | |||
| d54b2249b6 | |||
| f7be2c1e12 | |||
| 309568becc | |||
| dd9b6dbfe3 | |||
| 4692b48174 | |||
| db82fa3ae1 | |||
| 5c42507b12 | |||
| ebe7d87da7 | |||
| 3a6b7eed59 | |||
| 51d02d7764 | |||
| df39d61ed4 | |||
| 9cd2b1d8c5 | |||
| 49f1fb43fa | |||
| 7ec5d28caf | |||
| 23f5aa11b0 | |||
| 5fdf1df5df | |||
| 65b521ff8b | |||
| 6d578694e2 | |||
| f7ec649b24 | |||
| 71a9e1baef | |||
| 4a4adcb72e | |||
| 3458f03158 | |||
| 4fe4a01840 | |||
| e5d6fddeda | |||
| 370f5e3b8b | |||
| f5bb0820d5 | |||
| feb6da3ecb | |||
| 39f28a12aa | |||
| 416fc79637 | |||
| 1f43780bec | |||
| f9dd82010f | |||
| f0790b627d | |||
| 55350fffa0 | |||
| 7229602343 | |||
| 1c81c53699 | |||
| 5256d6197b | |||
| 79a6c8cdc0 | |||
| aa3b4d7d1e | |||
| cd220a4650 | |||
| d71b2a9ab8 | |||
| a2efe7243d | |||
| e0acda14e4 | |||
| 029ab8ea47 | |||
| 38f9498006 | |||
| 67fc3e5de2 | |||
| f1e6e9253f | |||
| 11c612e270 | |||
| cec5e49659 | |||
| 1dbdb5f2c3 | |||
| 086511d3e9 | |||
| 3d366d21b7 | |||
| 35f412dbd2 | |||
| c167aa0522 | |||
| fccb3f3d78 | |||
| 3a33283e94 | |||
| c74fb28a3a | |||
| ea504cc3ed | |||
| 61a2ad258e | |||
| ab62a8b1a9 | |||
| 479eb1272d | |||
| d23562e579 | |||
| 541d64bdd0 | |||
| d4f7e6e494 | |||
| 532c08fe2e | |||
| 704b9674f4 | |||
| 3de94280d2 | |||
| 65897789f6 | |||
| 5d097c3a95 | |||
| 4023e752a0 | |||
| 9a722b1a24 | |||
| 481b4b03dc | |||
| b7fd2f7902 | |||
| f2e1e59d6a | |||
| 3af2ecf1f4 | |||
| 1b2f2c891c | |||
| 155f3259f2 | |||
| f52d8d68b8 | |||
| 216d6e152c | |||
| b6f90e727c | |||
| 790bbc544f | |||
| bd511f7dc6 | |||
| e91c8c28a8 | |||
| 3c6d1afa97 | |||
| 3947e109b4 | |||
| 37b4727a29 | |||
| 2604d0002a | |||
| cca337ab31 | |||
| bb6e766a09 | |||
| af203ae51f | |||
| 01cbdde70e | |||
| e70ed311ed | |||
| c732cddf06 | |||
| 1f71f957e2 | |||
| 757c5fab19 | |||
| cfa537db1f | |||
| 8b18bef5ab | |||
| 76b01fb837 | |||
| 219ea593dd | |||
| 5c54e04b69 | |||
| bef07b1583 | |||
| 859762e35c | |||
| ca136b8e17 | |||
| 03d29a73f7 | |||
| c6ee9cda35 | |||
| ad3fefac0b | |||
| ad606cca53 | |||
| c0a9cb756f | |||
| 5fa00c0051 | |||
| 239e073a8c | |||
| bf87662f99 | |||
| 278ebf3472 | |||
| 4273edd836 | |||
| 7ce41fc1c1 | |||
| 7ade57e010 | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| 65a152cada | |||
| fb7a576e00 | |||
| 30a559b279 | |||
| f77d5fdf14 | |||
| 0a0667889c | |||
| 14d8cd54d7 | |||
| 5fa3d405e6 | |||
| 34eb335fd0 | |||
| c910530927 | |||
| 69e1a6cf6b | |||
| bd84613624 | |||
| 0b4777fc6b | |||
| e22813caec | |||
| 8f6e8432de | |||
| e4a6177cb5 | |||
| 34ffbca3e8 | |||
| f8acd8f3b6 | |||
| 9956f051ac | |||
| b33ae905a2 | |||
| 11eb0aa12a | |||
| 7c08321ce3 | |||
| e20becdca7 | |||
| 24897e25e2 | |||
| 2dc4cef583 | |||
| 34c95fbd81 | |||
| 9071db9b88 | |||
| 3eb2fdd7fa | |||
| 99e0d3d361 | |||
| a2eb89e230 | |||
| b21e953ef1 | |||
| 0ef086ce57 | |||
| 72d45746a5 | |||
| 9c22f41a3e | |||
| 22f001a735 | |||
| 26d464d3c7 | |||
| 3d6a3f8d04 | |||
| 39ce22a9e2 | |||
| 88f9a65d11 | |||
| 663ee12bcc | |||
| b3c98cecc3 | |||
| 49a18a977b | |||
| a5d0feeedf | |||
| a574e73b44 | |||
| a66f6a739f | |||
| cc7e1b54b6 | |||
| 28cb7fcd3d | |||
| aeb370beca | |||
| 239707e2da | |||
| c1e2778735 | |||
| fb608a554d | |||
| 7561065802 | |||
| 56c8d89999 | |||
| 9192760f3c | |||
| 8c201b5b4a | |||
| 5e19178bc0 | |||
| 107d9ca007 | |||
| 423695c24d | |||
| 4633c7253a | |||
| 8ace180fa8 | |||
| b9c3f2f0dd | |||
| 81b0eede8c | |||
| eb0cdbeba8 | |||
| ee212a0e48 | |||
| 2073516666 | |||
| 9d479b61d6 | |||
| 203e6bc4eb | |||
| 5f1ffbee4e | |||
| 40ec24db69 | |||
| ba8d0a3438 | |||
| 82decf99a6 | |||
| 6ba9fc1fec | |||
| 715d94c2ed | |||
| e1a722f479 | |||
| edbe12c512 | |||
| 9fc6542792 | |||
| 4c01ee26c2 | |||
| 813b9fcf61 | |||
| fe070e0177 | |||
| 423bb87ed8 | |||
| 1641f51b0c | |||
| 3f78a1f3d1 | |||
| b29dc63337 | |||
| 29699117dc | |||
| 3c75f9ecc6 | |||
| 79340703c1 | |||
| df23e3f96c | |||
| d9f788ddeb | |||
| 62afbdcaaa | |||
| 6c578cfd78 | |||
| a17abec799 | |||
| 2a71b70a34 | |||
| 03f77daf19 | |||
| 270b0c1af6 | |||
| 317bb523a4 | |||
| 2c8ad87b7e | |||
| 5e06729029 | |||
| 21bcfe1157 | |||
| 3aeaaaf4f2 | |||
| 3a9d1395db | |||
| 90c46d99d4 | |||
| 96f44fefd4 | |||
| 38a0a76b69 | |||
| 7fc73b6038 | |||
| 6b61dbc2da | |||
| fd3158fd15 | |||
| ff7135bf2c | |||
| 74bac570c7 | |||
| 5f999035c3 | |||
| fa7b5a3559 | |||
| 187821b2ae | |||
| 1435ba9658 | |||
| 62e2e1703c | |||
| 21a732379b | |||
| 8ac035d146 | |||
| d7e7fb065e | |||
| 11d3b8ab3b | |||
| 566e5996bc | |||
| 51618c7dbd | |||
| bdff3a6135 | |||
| ef7cd4ff5d | |||
| 431e437dee | |||
| cebd43e75a | |||
| 17bfbf95f2 | |||
| dad525be40 | |||
| 7dd0dbd594 | |||
| a0bf423a50 | |||
| 288b060983 | |||
| 5ba60d4fd0 | |||
| 07dae97fe6 | |||
| b210f67728 | |||
| 728d1d58c2 | |||
| 6b9650d451 | |||
| 72ae9072bf | |||
| e82263dc14 | |||
| f03b218775 | |||
| c840b59ae1 | |||
| 1213fc449a | |||
| ca21bb0f0c | |||
| 00555b2df6 | |||
| efca120470 | |||
| a178c3943a | |||
| 01ed1f20ad | |||
| e2bd67083e | |||
| 31fb0a87c9 | |||
| ac4d9fc602 | |||
| 8b1b581dbe | |||
| ebdaa24cfc | |||
| 5633e3adf8 | |||
| fcae5e066d | |||
| c312aea75f | |||
| 1e6e19ecd2 | |||
| 0866b04766 | |||
| 78cef8d58e | |||
| ce84aee8da | |||
| 1ba1665215 | |||
| 60fb18c8e2 | |||
| c042b490b8 | |||
| f544b46d97 | |||
| 70759724fe | |||
| fbfe252df6 | |||
| 2c3def8c7b | |||
| 47e67e8299 | |||
| ec15516230 | |||
| 462013bc2a | |||
| 6b5e53864d | |||
| a8a47589c8 | |||
| b9d567d421 | |||
| 81c77af558 | |||
| 1121680da6 | |||
| d31f2e8894 | |||
| 5895a59cb2 | |||
| 3e5e8d7a42 | |||
| 518a7fd2cf | |||
| 6c832d1754 | |||
| d898b5f23e | |||
| c38a1428f1 | |||
| 759eeccc1f | |||
| d0bc3b203c | |||
| 831b68b6cc | |||
| a06111f445 | |||
| 31fdd30c13 | |||
| e207ef89d5 | |||
| 1261da2e5b | |||
| 0c917bc41e | |||
| f525d6c7e6 | |||
| ed7c67a622 | |||
| 99281df5fb | |||
| 24c2fd6a15 | |||
| ec3fe34dc0 | |||
| 56f36da5f9 | |||
| 9bbd774175 | |||
| 020ac32ee6 | |||
| 67a72210ac | |||
| 020f41fd1e | |||
| 820eb8cc32 | |||
| 47fa5c2009 | |||
| 9b0c929423 | |||
| 93105a45fe | |||
| d8b2f4d367 | |||
| f1478bb2ca | |||
| 8b3c377688 | |||
| 8c98b02dca | |||
| 3743e35e8a | |||
| 05a02de4a9 | |||
| c28378cbb5 | |||
| b2bef63b6b | |||
| 6513e14b21 | |||
| fd53755ad6 | |||
| 1dbacb3027 | |||
| 910d9a7662 | |||
| 09bd8c6b21 | |||
| 908d108858 | |||
| 3135993cf4 | |||
| 7a315b5fd4 | |||
| 4bd6dcc3d7 | |||
| 3f7fa19cdf | |||
| 867ec4d125 | |||
| 164467f3a2 | |||
| fc9a2ddc2a | |||
| 543cb45c11 | |||
| c49e5adc52 | |||
| 0fedd446ca | |||
| 0c7b8a68d9 | |||
| 6dd6accbcc | |||
| ca67f7f79d | |||
| 1aa12c5857 | |||
| 80707fc438 | |||
| ff121dfeb8 | |||
| c3aa6a441b | |||
| 496d32e35b | |||
| 291fa58757 | |||
| eddbc2f986 | |||
| 81b8281d2c | |||
| 57f87d9a4c | |||
| c9d0c57d86 | |||
| 54ab5a9243 | |||
| 17b6b27cd7 | |||
| ed131ca1fd | |||
| 190d65cdee | |||
| dbf2e337f0 | |||
| 12e76bed4f | |||
| e00db80dae | |||
| 5de0aa8145 | |||
| 91ffb25027 | |||
| 6bcbdfedf0 | |||
| 3f42128cb9 | |||
| ccb8f98df5 | |||
| 591a597333 | |||
| 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 | |||
| 556c0e1db2 | |||
| 9897d3102e | |||
| 88dfb88bcc | |||
| 75bfe9b3bf | |||
| f4fe74f972 |
@@ -0,0 +1,20 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows scripts
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.jar binary
|
||||
*.aar binary
|
||||
*.keystore binary
|
||||
*.jks binary
|
||||
@@ -0,0 +1,3 @@
|
||||
github: zarzet
|
||||
ko_fi: zarzet
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'site/**'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -60,23 +60,23 @@ jobs:
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
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.25.7"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
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
|
||||
@@ -169,17 +169,17 @@ jobs:
|
||||
|
||||
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.25.7"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ios/Pods
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||
@@ -194,7 +194,7 @@ 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
|
||||
|
||||
@@ -249,23 +249,6 @@ jobs:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
||||
- name: Use iOS pubspec with FFmpeg plugin
|
||||
run: |
|
||||
cp pubspec.yaml pubspec_android_backup.yaml
|
||||
cp pubspec_ios.yaml pubspec.yaml
|
||||
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
|
||||
|
||||
# Swap FFmpeg service for iOS
|
||||
- name: Use iOS FFmpeg service
|
||||
run: |
|
||||
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
|
||||
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
|
||||
# Update class name in the swapped file
|
||||
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
|
||||
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
|
||||
echo "Swapped to iOS FFmpeg service"
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -312,7 +295,7 @@ jobs:
|
||||
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
|
||||
@@ -325,43 +308,33 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git-cliff
|
||||
|
||||
- name: Extract changelog for version
|
||||
- name: Generate changelog with git-cliff
|
||||
id: changelog
|
||||
uses: orhun/git-cliff-action@v4
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --latest --strip header
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OUTPUT: /tmp/changelog.txt
|
||||
|
||||
- name: Show generated changelog
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||
|
||||
echo "Looking for version: $VERSION_NUM"
|
||||
|
||||
# Extract changelog section for this version using sed
|
||||
# Find the line with version, then print until next version header or end
|
||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||
|
||||
# If no changelog found, use default message
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
echo "No changelog found for version $VERSION_NUM"
|
||||
CHANGELOG="See CHANGELOG.md for details."
|
||||
else
|
||||
echo "Found changelog content"
|
||||
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
||||
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
||||
fi
|
||||
|
||||
# Save to file for multiline support
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "Extracted changelog:"
|
||||
echo "Generated changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Download Android APK
|
||||
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
|
||||
@@ -369,15 +342,13 @@ jobs:
|
||||
- name: Prepare release body
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
cat > /tmp/release_body.txt << 'HEADER'
|
||||
### What's New
|
||||
HEADER
|
||||
|
||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
|
||||
|
||||
# Start with git-cliff changelog
|
||||
cp /tmp/changelog.txt /tmp/release_body.txt
|
||||
|
||||
# Append download section
|
||||
cat >> /tmp/release_body.txt << FOOTER
|
||||
|
||||
---
|
||||
@@ -402,7 +373,7 @@ jobs:
|
||||
cat /tmp/release_body.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
@@ -412,3 +383,125 @@ jobs:
|
||||
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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- 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: Generate changelog with git-cliff for Telegram
|
||||
uses: orhun/git-cliff-action@v4
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --latest --strip all
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OUTPUT: /tmp/cliff_tg.txt
|
||||
|
||||
- name: Convert changelog for Telegram
|
||||
id: changelog
|
||||
run: |
|
||||
if [ ! -s /tmp/cliff_tg.txt ]; then
|
||||
echo "See release notes on GitHub for details." > /tmp/changelog.txt
|
||||
else
|
||||
# Convert Markdown to Telegram HTML
|
||||
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||
sed 's/&/\&/g' | \
|
||||
sed 's/</\</g' | \
|
||||
sed 's/>/\>/g' | \
|
||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^- /• /g')
|
||||
|
||||
# Truncate for Telegram 4096 char limit
|
||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
fi
|
||||
|
||||
echo "Telegram 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!"
|
||||
|
||||
@@ -12,6 +12,9 @@ Thumbs.db
|
||||
# Kiro specs (development only)
|
||||
.kiro/
|
||||
|
||||
# Design assets (banners, mockups)
|
||||
design/
|
||||
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
@@ -72,3 +75,4 @@ flutter_*.log
|
||||
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -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! 💚
|
||||
@@ -1,20 +1,19 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="icon.png" width="128" />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/17247">
|
||||
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
@@ -24,14 +23,16 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
## Search Source
|
||||
<div align="center">
|
||||
|
||||
SpotiFLAC supports multiple search sources for finding music metadata:
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
| Source | Setup |
|
||||
|--------|-------|
|
||||
| **Deezer** (Default) | No setup required |
|
||||
| **Extensions** | Install additional search providers from the Store |
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
</div>
|
||||
|
||||
## Extensions
|
||||
|
||||
@@ -55,10 +56,10 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
|
||||
## 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.
|
||||
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music 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.
|
||||
A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
|
||||
|
||||
**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.
|
||||
@@ -69,19 +70,21 @@ A: The app needs permission to save downloaded files to your device. On Android
|
||||
**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).
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
**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.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
|
||||
**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.
|
||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||
|
||||
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
||||
[](https://ko-fi.com/zarzet)
|
||||
|
||||
You are solely responsible for:
|
||||
1. Ensuring your use of this software complies with your local laws.
|
||||
2. Reading and adhering to the Terms of Service of the respective platforms.
|
||||
3. Any legal consequences resulting from the misuse of this tool.
|
||||
## API Credits
|
||||
|
||||
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.
|
||||
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
@@ -96,11 +96,13 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
|
||||
// Include all AAR and JAR files from libs folder
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
-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.**
|
||||
@@ -14,13 +15,22 @@
|
||||
# Ignore missing javax.xml.stream (not used on Android)
|
||||
-dontwarn javax.xml.stream.**
|
||||
|
||||
# Go backend (gobackend.aar)
|
||||
# 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.**
|
||||
@@ -30,15 +40,77 @@
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Kotlin coroutines
|
||||
# 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 { *; }
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<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.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<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" />
|
||||
@@ -20,9 +21,10 @@
|
||||
android:label="SpotiFLAC"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
android:usesCleartextTraffic="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -43,7 +45,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" />
|
||||
@@ -57,6 +59,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 -->
|
||||
@@ -65,6 +94,24 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Audio playback service for media notification / background audio -->
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 651 B After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1a1a2e</color>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="es-ES" />
|
||||
<locale android:name="id" />
|
||||
<locale android:name="pt-PT" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="tr" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="hi" />
|
||||
<locale android:name="ko" />
|
||||
<locale android:name="nl" />
|
||||
<locale android:name="zh" />
|
||||
</locale-config>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
|
||||
<!-- Allow local loopback cleartext for FFmpeg live decrypt tunnel only. -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -22,7 +22,7 @@ subprojects {
|
||||
}
|
||||
|
||||
// Add desugaring dependency to all Android subprojects
|
||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
|
||||
@@ -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.3.0" 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")
|
||||
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -1,335 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('FFmpeg');
|
||||
|
||||
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
||||
class FFmpegServiceIOS {
|
||||
/// Execute FFmpeg command and return result
|
||||
static Future<FFmpegResultIOS> _execute(String command) async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
final output = await session.getOutput() ?? '';
|
||||
return FFmpegResultIOS(
|
||||
success: ReturnCode.isSuccess(returnCode),
|
||||
returnCode: returnCode?.getValue() ?? -1,
|
||||
output: output,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('FFmpeg execute error: $e');
|
||||
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert M4A (DASH segments) to FLAC
|
||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
} catch (_) {}
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC to MP3
|
||||
/// If deleteOriginal is true, deletes the FLAC file after conversion
|
||||
static Future<String?> convertFlacToMp3(
|
||||
String inputPath, {
|
||||
String bitrate = '320k',
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
// Convert in same folder, just change extension
|
||||
final outputPath = inputPath.replaceAll('.flac', '.mp3');
|
||||
|
||||
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
// Delete original FLAC if requested
|
||||
if (deleteOriginal) {
|
||||
try {
|
||||
await File(inputPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC to M4A
|
||||
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
||||
final dir = File(inputPath).parent.path;
|
||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||
await Directory(outputDir).create(recursive: true);
|
||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
||||
|
||||
String command;
|
||||
if (codec == 'alac') {
|
||||
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
} else {
|
||||
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||
}
|
||||
|
||||
final result = await _execute(command);
|
||||
if (result.success) return outputPath;
|
||||
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed cover art to FLAC file
|
||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
||||
final tempOutput = '$flacPath.tmp';
|
||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(flacPath).delete();
|
||||
await File(tempOutput).rename(flacPath);
|
||||
return flacPath;
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace file after cover embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) await tempFile.delete();
|
||||
} catch (_) {}
|
||||
|
||||
_log.e('Cover embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover art to FLAC file
|
||||
/// Returns the file path on success, null on failure
|
||||
static Future<String?> embedMetadata({
|
||||
required String flacPath,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempOutput = '$flacPath.tmp';
|
||||
|
||||
// Construct command
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$flacPath" ');
|
||||
|
||||
// Add cover input if available
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
// Map audio stream
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
|
||||
// Map cover stream if available
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v copy ');
|
||||
cmdBuffer.write('-disposition:v attached_pic ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
|
||||
// Copy audio codec (don't re-encode)
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
// Add text metadata
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
// Sanitize value: escape double quotes
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg command: $command');
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(flacPath).delete();
|
||||
await File(tempOutput).rename(flacPath);
|
||||
return flacPath;
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace file after metadata embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp file if exists
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover art to MP3 file using ID3v2 tags
|
||||
/// Returns the file path on success, null on failure
|
||||
static Future<String?> embedMetadataToMp3({
|
||||
required String mp3Path,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempOutput = '$mp3Path.tmp';
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$mp3Path" ');
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
|
||||
if (coverPath != null) {
|
||||
cmdBuffer.write('-map 1:0 ');
|
||||
cmdBuffer.write('-c:v:0 copy ');
|
||||
cmdBuffer.write('-id3v2_version 3 ');
|
||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
if (metadata != null) {
|
||||
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
|
||||
final id3Metadata = _convertToId3Tags(metadata);
|
||||
id3Metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
});
|
||||
}
|
||||
|
||||
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('Executing FFmpeg MP3 embed command: $command');
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
await File(mp3Path).delete();
|
||||
await File(tempOutput).rename(mp3Path);
|
||||
_log.d('MP3 metadata embedded successfully');
|
||||
return mp3Path;
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace MP3 file after metadata embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
|
||||
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
||||
final id3Map = <String, String>{};
|
||||
|
||||
for (final entry in vorbisMetadata.entries) {
|
||||
final key = entry.key.toUpperCase();
|
||||
final value = entry.value;
|
||||
|
||||
// Map Vorbis comments to ID3v2 frame names
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
id3Map['title'] = value;
|
||||
break;
|
||||
case 'ARTIST':
|
||||
id3Map['artist'] = value;
|
||||
break;
|
||||
case 'ALBUM':
|
||||
id3Map['album'] = value;
|
||||
break;
|
||||
case 'ALBUMARTIST':
|
||||
id3Map['album_artist'] = value;
|
||||
break;
|
||||
case 'TRACKNUMBER':
|
||||
case 'TRACK':
|
||||
id3Map['track'] = value;
|
||||
break;
|
||||
case 'DISCNUMBER':
|
||||
case 'DISC':
|
||||
id3Map['disc'] = value;
|
||||
break;
|
||||
case 'DATE':
|
||||
case 'YEAR':
|
||||
id3Map['date'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
id3Map['TSRC'] = value; // ID3v2 ISRC frame
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
id3Map['lyrics'] = value;
|
||||
break;
|
||||
default:
|
||||
// Pass through other tags as-is
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return id3Map;
|
||||
}
|
||||
|
||||
/// Check if FFmpeg is available
|
||||
static Future<bool> isAvailable() async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute('-version');
|
||||
final returnCode = await session.getReturnCode();
|
||||
return ReturnCode.isSuccess(returnCode);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get FFmpeg version info
|
||||
static Future<String?> getVersion() async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute('-version');
|
||||
return await session.getOutput();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FFmpegResultIOS {
|
||||
final bool success;
|
||||
final int returnCode;
|
||||
final String output;
|
||||
|
||||
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# git-cliff configuration for SpotiFLAC Mobile
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# Template for the changelog body
|
||||
body = """
|
||||
{%- macro remote_url() -%}
|
||||
https://github.com/zarzet/SpotiFLAC-Mobile
|
||||
{%- endmacro -%}
|
||||
|
||||
{% if version %}\
|
||||
## {{ version | trim_start_matches(pat="v") }}
|
||||
{% else %}\
|
||||
## Unreleased
|
||||
{% endif %}\
|
||||
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
||||
{{ commit.message | upper_first }}\
|
||||
{% if commit.github.pr_number %} \
|
||||
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
||||
{% endif %}\
|
||||
{%- if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||
{%- endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
|
||||
|
||||
### New Contributors
|
||||
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
|
||||
* @{{ contributor.username }} made their first contribution
|
||||
{%- if contributor.pr_number %} in \
|
||||
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif -%}
|
||||
|
||||
{% if version %}
|
||||
{% if previous.version %}
|
||||
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
|
||||
{% endif %}
|
||||
{% else -%}
|
||||
{% raw %}\n{% endraw %}
|
||||
{% endif %}
|
||||
"""
|
||||
# Remove leading and trailing whitespace
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
# Parse conventional commits
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
|
||||
# Process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
|
||||
# Regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
# Remove PR number from message (we add it back via GitHub integration)
|
||||
{ pattern = '\(#(\d+)\)', replace = '' },
|
||||
# Strip conventional commit prefix for cleaner messages
|
||||
# (group header already shows the type)
|
||||
]
|
||||
|
||||
# Regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
# Skip noise: translation commits from Crowdin
|
||||
{ message = "^New translations", skip = true },
|
||||
{ message = "^Update source file", skip = true },
|
||||
# Skip merge commits
|
||||
{ message = "^Merge", skip = true },
|
||||
# Skip version bump commits
|
||||
{ message = "^v\\d+", skip = true },
|
||||
{ message = "^chore: update VirusTotal", skip = true },
|
||||
|
||||
# Group by conventional commit type
|
||||
{ message = "^feat", group = "<!-- 0 -->New Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
|
||||
{ message = "^doc", group = "<!-- 4 -->Documentation" },
|
||||
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
|
||||
{ message = "^chore\\(l10n\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
|
||||
]
|
||||
|
||||
# Protect breaking changes from being skipped
|
||||
protect_breaking_commits = true
|
||||
|
||||
# Filter out commits by matching patterns
|
||||
filter_commits = false
|
||||
|
||||
# Tag pattern for version detection
|
||||
tag_pattern = "v[0-9].*"
|
||||
|
||||
# Sort commits by newest first
|
||||
sort_commits = "newest"
|
||||
|
||||
[remote.github]
|
||||
owner = "zarzet"
|
||||
repo = "SpotiFLAC-Mobile"
|
||||
@@ -1,637 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
regions []string // us, eu regions for DoubleDouble service
|
||||
lastAPICallTime time.Time // Rate limiting: track last API call
|
||||
apiCallCount int // Rate limiting: counter per minute
|
||||
apiCallResetTime time.Time // Rate limiting: reset time
|
||||
}
|
||||
|
||||
var (
|
||||
globalAmazonDownloader *AmazonDownloader
|
||||
amazonDownloaderOnce sync.Once
|
||||
amazonRateLimitMu sync.Mutex
|
||||
)
|
||||
|
||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||
type DoubleDoubleSubmitResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// amazonArtistsMatch checks if the artist names are similar enough
|
||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||
|
||||
foundFirst := strings.Split(normFound, ",")[0]
|
||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||
foundFirst = strings.TrimSpace(foundFirst)
|
||||
|
||||
if expectedFirst == foundFirst {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||
return true
|
||||
}
|
||||
|
||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||
foundASCII := amazonIsASCIIString(foundArtist)
|
||||
if expectedASCII != foundASCII {
|
||||
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||
func amazonIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||
regions: []string{"us", "eu"}, // Same regions as PC
|
||||
apiCallResetTime: time.Now(),
|
||||
}
|
||||
})
|
||||
return globalAmazonDownloader
|
||||
}
|
||||
|
||||
// waitForRateLimit implements rate limiting similar to PC version
|
||||
// Max 9 requests per minute with 7 second delay between requests
|
||||
func (a *AmazonDownloader) waitForRateLimit() {
|
||||
amazonRateLimitMu.Lock()
|
||||
defer amazonRateLimitMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit (9 requests per minute), wait until next minute
|
||||
if a.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (7 seconds like PC version)
|
||||
if !a.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
|
||||
a.lastAPICallTime = time.Now()
|
||||
a.apiCallCount++
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
return apis
|
||||
}
|
||||
|
||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||
// This uses submit → poll → download mechanism
|
||||
// Internal function - not exported to gomobile
|
||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) {
|
||||
var lastError error
|
||||
|
||||
for _, region := range a.regions {
|
||||
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
// Step 1: Submit download request with rate limiting
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
// Apply rate limiting before request (like PC version)
|
||||
a.waitForRateLimit()
|
||||
|
||||
req, err := http.NewRequest("GET", submitURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
fmt.Println("[Amazon] Submitting download request...")
|
||||
|
||||
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
||||
var resp *http.Response
|
||||
maxRetries := 3
|
||||
for retry := 0; retry < maxRetries; retry++ {
|
||||
resp, err = a.client.Do(req)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||
break
|
||||
}
|
||||
|
||||
if resp.StatusCode == 429 { // Too Many Requests
|
||||
resp.Body.Close()
|
||||
if retry < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
lastError = fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
||||
break
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||
break
|
||||
}
|
||||
|
||||
// Success - break retry loop
|
||||
break
|
||||
}
|
||||
|
||||
if err != nil || lastError != nil {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var submitResp DoubleDoubleSubmitResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !submitResp.Success || submitResp.ID == "" {
|
||||
lastError = fmt.Errorf("submit request failed")
|
||||
continue
|
||||
}
|
||||
|
||||
downloadID := submitResp.ID
|
||||
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
||||
|
||||
// Step 2: Poll for completion
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
fmt.Println("[Amazon] Waiting for download to complete...")
|
||||
|
||||
maxWait := 300 * time.Second // 5 minutes max wait
|
||||
elapsed := time.Duration(0)
|
||||
pollInterval := 3 * time.Second
|
||||
|
||||
for elapsed < maxWait {
|
||||
time.Sleep(pollInterval)
|
||||
elapsed += pollInterval
|
||||
|
||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
statusResp, err := a.client.Do(statusReq)
|
||||
if err != nil {
|
||||
fmt.Printf("\r[Amazon] Status check failed, retrying...")
|
||||
continue
|
||||
}
|
||||
|
||||
if statusResp.StatusCode != 200 {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var status DoubleDoubleStatusResponse
|
||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
||||
statusResp.Body.Close()
|
||||
fmt.Printf("\r[Amazon] Invalid JSON response, retrying...")
|
||||
continue
|
||||
}
|
||||
statusResp.Body.Close()
|
||||
|
||||
if status.Status == "done" {
|
||||
fmt.Println("\n[Amazon] Download ready!")
|
||||
|
||||
fileURL := status.URL
|
||||
if strings.HasPrefix(fileURL, "./") {
|
||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||
} else if strings.HasPrefix(fileURL, "/") {
|
||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
||||
}
|
||||
|
||||
trackName := status.Current.Name
|
||||
artist := status.Current.Artist
|
||||
|
||||
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||
return fileURL, trackName, artist, nil
|
||||
|
||||
} else if status.Status == "error" {
|
||||
errorMsg := status.FriendlyStatus
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Unknown error"
|
||||
}
|
||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
||||
break
|
||||
} else {
|
||||
// Still processing
|
||||
friendlyStatus := status.FriendlyStatus
|
||||
if friendlyStatus == "" {
|
||||
friendlyStatus = status.Status
|
||||
}
|
||||
fmt.Printf("\r[Amazon] %s...", friendlyStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed >= maxWait {
|
||||
lastError = fmt.Errorf("download timeout")
|
||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
||||
continue
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
// Flush buffer before checking for errors
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
// Verify file size if Content-Length was provided
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
// AmazonDownloadResult contains download result with quality info
|
||||
type AmazonDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// downloadFromAmazon downloads a track using the request parameters
|
||||
// Uses DoubleDouble service (same as PC version)
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
if req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download using DoubleDouble service (same as PC)
|
||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Verify artist matches
|
||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
req.CoverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.EmbedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Log track info from DoubleDouble (for debugging)
|
||||
if trackName != "" && artistName != "" {
|
||||
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||
}
|
||||
|
||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||
actualTrackNum := req.TrackNumber
|
||||
actualDiscNum := req.DiscNumber
|
||||
|
||||
if metaErr == nil && existingMeta != nil {
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
actualTrackNum = existingMeta.TrackNumber
|
||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
}
|
||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||
actualDiscNum = existingMeta.DiscNumber
|
||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||
}
|
||||
}
|
||||
|
||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
||||
// But preserve track/disc numbers from file if they were better
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre, // From Deezer album metadata
|
||||
Label: req.Label, // From Deezer album metadata
|
||||
Copyright: req.Copyright, // From Deezer album metadata
|
||||
}
|
||||
|
||||
// Use cover data from parallel fetch
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
||||
}
|
||||
|
||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
}
|
||||
}
|
||||
|
||||
// Add to ISRC index for fast duplicate checking
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
|
||||
bitDepth := 0
|
||||
sampleRate := 0
|
||||
if err == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
}, nil
|
||||
}
|
||||
@@ -8,18 +8,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Spotify image size codes (same as PC version)
|
||||
const (
|
||||
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||
spotifySize300 = "ab67616d00001e02"
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
||||
// Same logic as PC version for consistency
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
@@ -27,8 +24,6 @@ func convertSmallToMedium(imageURL string) string {
|
||||
return imageURL
|
||||
}
|
||||
|
||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
||||
// This avoids file permission issues on Android
|
||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
if coverURL == "" {
|
||||
return nil, fmt.Errorf("no cover URL provided")
|
||||
@@ -90,8 +85,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// upgradeToMaxQuality upgrades cover URL to maximum quality
|
||||
// Supports both Spotify and Deezer CDNs
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify CDN upgrade
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
@@ -106,9 +99,6 @@ func upgradeToMaxQuality(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
|
||||
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
|
||||
// Available sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
func upgradeDeezerCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return coverURL
|
||||
@@ -122,7 +112,6 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return upgraded
|
||||
}
|
||||
|
||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CueSheet represents a parsed .cue file
|
||||
type CueSheet struct {
|
||||
// Album-level metadata
|
||||
Performer string `json:"performer"`
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Tracks []CueTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
// CueTrack represents a single track in a cue sheet
|
||||
type CueTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Performer string `json:"performer"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
// Index positions in seconds (fractional)
|
||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||
}
|
||||
|
||||
// CueSplitInfo represents the information needed to split a CUE+audio file
|
||||
type CueSplitInfo struct {
|
||||
CuePath string `json:"cue_path"`
|
||||
AudioPath string `json:"audio_path"`
|
||||
Album string `json:"album"`
|
||||
Artist string `json:"artist"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Tracks []CueSplitTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
// CueSplitTrack has the FFmpeg split parameters for a single track
|
||||
type CueSplitTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
StartSec float64 `json:"start_sec"`
|
||||
EndSec float64 `json:"end_sec"` // -1 means until end of file
|
||||
}
|
||||
|
||||
var (
|
||||
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
|
||||
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
||||
)
|
||||
|
||||
// ParseCueFile parses a .cue file and returns a CueSheet
|
||||
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
f, err := os.Open(cuePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open cue file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sheet := &CueSheet{}
|
||||
var currentTrack *CueTrack
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle BOM at start of file
|
||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||
line = strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(line)
|
||||
|
||||
// REM commands (album-level metadata)
|
||||
if strings.HasPrefix(upper, "REM ") {
|
||||
matches := reRemCommand.FindStringSubmatch(line)
|
||||
if len(matches) == 3 {
|
||||
key := strings.ToUpper(matches[1])
|
||||
value := unquoteCue(matches[2])
|
||||
switch key {
|
||||
case "GENRE":
|
||||
sheet.Genre = value
|
||||
case "DATE":
|
||||
sheet.Date = value
|
||||
case "COMMENT":
|
||||
sheet.Comment = value
|
||||
case "COMPOSER":
|
||||
if currentTrack != nil {
|
||||
currentTrack.Composer = value
|
||||
} else {
|
||||
sheet.Composer = value
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// PERFORMER
|
||||
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||
value := unquoteCue(line[len("PERFORMER "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Performer = value
|
||||
} else {
|
||||
sheet.Performer = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// TITLE
|
||||
if strings.HasPrefix(upper, "TITLE ") {
|
||||
value := unquoteCue(line[len("TITLE "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Title = value
|
||||
} else {
|
||||
sheet.Title = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// FILE
|
||||
if strings.HasPrefix(upper, "FILE ") {
|
||||
rest := line[len("FILE "):]
|
||||
// Extract filename and type
|
||||
// Format: FILE "filename.flac" WAVE
|
||||
// or: FILE filename.flac WAVE
|
||||
fname, ftype := parseCueFileLine(rest)
|
||||
sheet.FileName = fname
|
||||
sheet.FileType = ftype
|
||||
continue
|
||||
}
|
||||
|
||||
// TRACK
|
||||
if strings.HasPrefix(upper, "TRACK ") {
|
||||
// Save previous track
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
parts := strings.Fields(line)
|
||||
trackNum := 0
|
||||
if len(parts) >= 2 {
|
||||
trackNum, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
|
||||
currentTrack = &CueTrack{
|
||||
Number: trackNum,
|
||||
PreGap: -1,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// INDEX
|
||||
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 {
|
||||
indexNum, _ := strconv.Atoi(parts[1])
|
||||
timeSec := parseCueTimestamp(parts[2])
|
||||
switch indexNum {
|
||||
case 0:
|
||||
currentTrack.PreGap = timeSec
|
||||
case 1:
|
||||
currentTrack.StartTime = timeSec
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// ISRC
|
||||
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||
continue
|
||||
}
|
||||
|
||||
// SONGWRITER (used as composer sometimes)
|
||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||
value := unquoteCue(line[len("SONGWRITER "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Composer = value
|
||||
} else {
|
||||
sheet.Composer = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last track
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading cue file: %w", err)
|
||||
}
|
||||
|
||||
if len(sheet.Tracks) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found in cue file")
|
||||
}
|
||||
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
|
||||
func parseCueTimestamp(ts string) float64 {
|
||||
parts := strings.Split(ts, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
|
||||
minutes, _ := strconv.Atoi(parts[0])
|
||||
seconds, _ := strconv.Atoi(parts[1])
|
||||
frames, _ := strconv.Atoi(parts[2])
|
||||
|
||||
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
|
||||
}
|
||||
|
||||
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
|
||||
func formatCueTimestamp(seconds float64) string {
|
||||
if seconds < 0 {
|
||||
return "0"
|
||||
}
|
||||
hours := int(seconds) / 3600
|
||||
mins := (int(seconds) % 3600) / 60
|
||||
secs := seconds - float64(hours*3600) - float64(mins*60)
|
||||
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
||||
}
|
||||
|
||||
// unquoteCue removes surrounding quotes from a CUE value
|
||||
func unquoteCue(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
||||
return matches[1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseCueFileLine parses the FILE command's filename and type
|
||||
func parseCueFileLine(rest string) (string, string) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
|
||||
var filename, ftype string
|
||||
|
||||
if strings.HasPrefix(rest, "\"") {
|
||||
// Quoted filename
|
||||
endQuote := strings.Index(rest[1:], "\"")
|
||||
if endQuote >= 0 {
|
||||
filename = rest[1 : endQuote+1]
|
||||
remaining := strings.TrimSpace(rest[endQuote+2:])
|
||||
ftype = remaining
|
||||
} else {
|
||||
filename = rest
|
||||
}
|
||||
} else {
|
||||
// Unquoted filename - last word is the type
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) >= 2 {
|
||||
ftype = parts[len(parts)-1]
|
||||
filename = strings.Join(parts[:len(parts)-1], " ")
|
||||
} else if len(parts) == 1 {
|
||||
filename = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
return filename, strings.TrimSpace(ftype)
|
||||
}
|
||||
|
||||
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
|
||||
// It checks relative to the cue file's directory.
|
||||
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
cueDir := filepath.Dir(cuePath)
|
||||
|
||||
// 1. Try the exact filename from the .cue
|
||||
candidate := filepath.Join(cueDir, cueFileName)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
|
||||
// 2. Try common case variations
|
||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, baseName+ext)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
// Try uppercase ext
|
||||
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try to find any audio file with the same base name as the .cue file
|
||||
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, cueBase+ext)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If there's only one audio file in the directory, use that
|
||||
entries, err := os.ReadDir(cueDir)
|
||||
if err == nil {
|
||||
audioExts := map[string]bool{
|
||||
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
|
||||
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
|
||||
}
|
||||
var audioFiles []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||
if audioExts[ext] {
|
||||
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
|
||||
}
|
||||
}
|
||||
if len(audioFiles) == 1 {
|
||||
return audioFiles[0]
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
|
||||
// This is returned to the Dart side so FFmpeg can perform the splitting.
|
||||
// audioDir, if non-empty, overrides the directory for audio file resolution.
|
||||
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
|
||||
resolveDir := cuePath
|
||||
if audioDir != "" {
|
||||
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
|
||||
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
||||
if audioPath == "" {
|
||||
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||
}
|
||||
|
||||
info := &CueSplitInfo{
|
||||
CuePath: cuePath,
|
||||
AudioPath: audioPath,
|
||||
Album: sheet.Title,
|
||||
Artist: sheet.Performer,
|
||||
Genre: sheet.Genre,
|
||||
Date: sheet.Date,
|
||||
}
|
||||
|
||||
for i, track := range sheet.Tracks {
|
||||
performer := track.Performer
|
||||
if performer == "" {
|
||||
performer = sheet.Performer
|
||||
}
|
||||
|
||||
composer := track.Composer
|
||||
if composer == "" {
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
// End time is the start of the next track, or -1 for the last track
|
||||
endSec := float64(-1)
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextTrack := sheet.Tracks[i+1]
|
||||
// Use pre-gap of next track if available, otherwise its start time
|
||||
if nextTrack.PreGap >= 0 {
|
||||
endSec = nextTrack.PreGap
|
||||
} else {
|
||||
endSec = nextTrack.StartTime
|
||||
}
|
||||
}
|
||||
|
||||
info.Tracks = append(info.Tracks, CueSplitTrack{
|
||||
Number: track.Number,
|
||||
Title: track.Title,
|
||||
Artist: performer,
|
||||
ISRC: track.ISRC,
|
||||
Composer: composer,
|
||||
StartSec: track.StartTime,
|
||||
EndSec: endSec,
|
||||
})
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
|
||||
// This is the main entry point called from Dart via the platform bridge.
|
||||
// audioDir, if non-empty, overrides the directory used for resolving the
|
||||
// referenced audio file (useful when the .cue was copied to a temp dir
|
||||
// but the audio still lives in the original location, e.g. SAF).
|
||||
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse cue file: %w", err)
|
||||
}
|
||||
|
||||
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
|
||||
// entries, one per track. This is used by the library scanner to populate the
|
||||
// library with individual track entries from a single CUE+FLAC album.
|
||||
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
||||
return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime)
|
||||
}
|
||||
|
||||
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||
// for SAF (Storage Access Framework) scenarios:
|
||||
// - audioDir: if non-empty, overrides the directory used to find the audio file
|
||||
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
|
||||
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
|
||||
// - fileModTime: if > 0, used as the FileModTime for all results instead of
|
||||
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
|
||||
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||
return scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
|
||||
}
|
||||
|
||||
func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve audio file — optionally in an overridden directory
|
||||
resolveBase := cuePath
|
||||
if audioDir != "" {
|
||||
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||
if audioPath == "" {
|
||||
return nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||
}
|
||||
|
||||
// Try to get quality info from the audio file
|
||||
var bitDepth, sampleRate int
|
||||
var totalDurationSec float64
|
||||
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
||||
switch audioExt {
|
||||
case ".flac":
|
||||
quality, qErr := GetAudioQuality(audioPath)
|
||||
if qErr == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
|
||||
}
|
||||
}
|
||||
case ".mp3":
|
||||
quality, qErr := GetMP3Quality(audioPath)
|
||||
if qErr == nil {
|
||||
sampleRate = quality.SampleRate
|
||||
totalDurationSec = float64(quality.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract cover from audio file for all tracks
|
||||
var coverPath string
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" {
|
||||
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
|
||||
if err == nil && cp != "" {
|
||||
coverPath = cp
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the base path for virtual paths and IDs
|
||||
pathBase := cuePath
|
||||
if virtualPathPrefix != "" {
|
||||
pathBase = virtualPathPrefix
|
||||
}
|
||||
|
||||
// Determine fileModTime
|
||||
modTime := fileModTime
|
||||
if modTime <= 0 {
|
||||
if info, err := os.Stat(cuePath); err == nil {
|
||||
modTime = info.ModTime().UnixMilli()
|
||||
}
|
||||
}
|
||||
|
||||
var results []LibraryScanResult
|
||||
for i, track := range sheet.Tracks {
|
||||
performer := track.Performer
|
||||
if performer == "" {
|
||||
performer = sheet.Performer
|
||||
}
|
||||
if performer == "" {
|
||||
performer = "Unknown Artist"
|
||||
}
|
||||
|
||||
title := track.Title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Track %02d", track.Number)
|
||||
}
|
||||
|
||||
album := sheet.Title
|
||||
if album == "" {
|
||||
album = "Unknown Album"
|
||||
}
|
||||
|
||||
// Calculate duration for this track
|
||||
var duration int
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextStart := sheet.Tracks[i+1].StartTime
|
||||
if sheet.Tracks[i+1].PreGap >= 0 {
|
||||
nextStart = sheet.Tracks[i+1].PreGap
|
||||
}
|
||||
duration = int(nextStart - track.StartTime)
|
||||
} else if totalDurationSec > 0 {
|
||||
duration = int(totalDurationSec - track.StartTime)
|
||||
}
|
||||
|
||||
// Use a unique ID based on pathBase + track number
|
||||
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
|
||||
|
||||
// Use a virtual file path that includes the track number to ensure
|
||||
// uniqueness in the database (file_path has a UNIQUE constraint).
|
||||
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
|
||||
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
|
||||
|
||||
result := LibraryScanResult{
|
||||
ID: id,
|
||||
TrackName: title,
|
||||
ArtistName: performer,
|
||||
AlbumName: album,
|
||||
AlbumArtist: sheet.Performer,
|
||||
FilePath: virtualFilePath,
|
||||
CoverPath: coverPath,
|
||||
ScannedAt: scanTime,
|
||||
ISRC: track.ISRC,
|
||||
TrackNumber: track.Number,
|
||||
DiscNumber: 1,
|
||||
Duration: duration,
|
||||
ReleaseDate: sheet.Date,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Genre: sheet.Genre,
|
||||
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||
}
|
||||
|
||||
result.FileModTime = modTime
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
|
||||
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
|
||||
|
||||
type YoinkifyRequest struct {
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
GenreSource string `json:"genreSource"`
|
||||
}
|
||||
|
||||
type DeezerDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) {
|
||||
rawSpotify := strings.TrimSpace(req.SpotifyID)
|
||||
if rawSpotify != "" {
|
||||
if isLikelySpotifyTrackID(rawSpotify) {
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil
|
||||
}
|
||||
|
||||
if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" {
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil
|
||||
}
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if deezerID == "" {
|
||||
if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found {
|
||||
deezerID = strings.TrimSpace(prefixed)
|
||||
}
|
||||
}
|
||||
|
||||
if deezerID != "" {
|
||||
songlink := NewSongLinkClient()
|
||||
spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err)
|
||||
}
|
||||
spotifyID = strings.TrimSpace(spotifyID)
|
||||
if spotifyID == "" {
|
||||
return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID)
|
||||
}
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify")
|
||||
}
|
||||
|
||||
func isLikelySpotifyTrackID(value string) bool {
|
||||
if len(value) != 22 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error {
|
||||
payload := YoinkifyRequest{
|
||||
URL: spotifyURL,
|
||||
Format: "flac",
|
||||
GenreSource: "spotify",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode Yoinkify request: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Yoinkify request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := GetDownloadClient().Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("failed to call Yoinkify: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyText != "" {
|
||||
return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText)
|
||||
}
|
||||
return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyText == "" {
|
||||
bodyText = "empty JSON payload"
|
||||
}
|
||||
return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if deezerID == "" {
|
||||
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
|
||||
deezerID = strings.TrimSpace(prefixed)
|
||||
}
|
||||
}
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
}
|
||||
|
||||
// Try resolving Deezer ID from Spotify ID via SongLink
|
||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try resolving from ISRC
|
||||
isrc := strings.TrimSpace(req.ISRC)
|
||||
if isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||
if err == nil && track != nil {
|
||||
deezerID = songLinkExtractDeezerTrackID(track)
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
type deezerMusicDLRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
|
||||
payload := deezerMusicDLRequest{
|
||||
Platform: "deezer",
|
||||
URL: deezerTrackURL,
|
||||
}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MusicDL request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try various response fields for download URL
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL found in MusicDL response")
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
||||
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
||||
|
||||
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
||||
|
||||
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 download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := GetDownloadClient().Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||
if err != nil {
|
||||
return DeezerDownloadResult{}, err
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
|
||||
// Try MusicDL first (better quality), fallback to Yoinkify
|
||||
var downloadErr error
|
||||
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
||||
if deezerURLErr == nil {
|
||||
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
||||
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr)
|
||||
}
|
||||
|
||||
if downloadErr != nil || deezerURLErr != nil {
|
||||
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
|
||||
}
|
||||
}
|
||||
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
}
|
||||
|
||||
if isSafOutput || !req.EmbedMetadata {
|
||||
if !req.EmbedMetadata {
|
||||
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||
} else {
|
||||
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
bitDepth, sampleRate := 0, 0
|
||||
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return DeezerDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
|
||||
type ISRCIndex struct {
|
||||
index map[string]string // ISRC (uppercase) -> file path
|
||||
outputDir string
|
||||
@@ -25,8 +24,6 @@ var (
|
||||
isrcIndexTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// GetISRCIndex returns or builds an ISRC index for the given directory
|
||||
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
|
||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
// Fast path: check cache first
|
||||
isrcIndexCacheMu.RLock()
|
||||
@@ -56,7 +53,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
return buildISRCIndex(outputDir)
|
||||
}
|
||||
|
||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
idx := &ISRCIndex{
|
||||
index: make(map[string]string),
|
||||
@@ -91,7 +87,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||
|
||||
isrcIndexCacheMu.Lock()
|
||||
@@ -113,7 +109,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||
return path, exists
|
||||
}
|
||||
|
||||
// remove deletes an ISRC entry from the index (internal use)
|
||||
func (idx *ISRCIndex) remove(isrc string) {
|
||||
if isrc == "" {
|
||||
return
|
||||
@@ -125,14 +120,11 @@ func (idx *ISRCIndex) remove(isrc string) {
|
||||
delete(idx.index, strings.ToUpper(isrc))
|
||||
}
|
||||
|
||||
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||
// Returns filepath if found, empty string if not found
|
||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||
path, _ := idx.lookup(isrc)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Add adds a new ISRC to the index (call after successful download)
|
||||
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||
if isrc == "" || filePath == "" {
|
||||
return
|
||||
@@ -144,21 +136,17 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||
idx.index[strings.ToUpper(isrc)] = filePath
|
||||
}
|
||||
|
||||
// InvalidateCache clears the ISRC index cache for a directory
|
||||
func InvalidateISRCCache(outputDir string) {
|
||||
isrcIndexCacheMu.Lock()
|
||||
delete(isrcIndexCache, outputDir)
|
||||
isrcIndexCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
||||
// Uses ISRC index for fast lookup
|
||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
if isrc == "" || outputDir == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Use index for fast lookup
|
||||
idx := GetISRCIndex(outputDir)
|
||||
filePath, exists := idx.lookup(isrc)
|
||||
if !exists {
|
||||
@@ -174,14 +162,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
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 {
|
||||
@@ -190,7 +175,6 @@ func CheckFileExists(filePath string) bool {
|
||||
return !info.IsDir() && info.Size() > 0
|
||||
}
|
||||
|
||||
// FileExistenceResult represents the result of checking if a file exists
|
||||
type FileExistenceResult struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Exists bool `json:"exists"`
|
||||
@@ -199,9 +183,6 @@ type FileExistenceResult struct {
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
|
||||
// CheckFilesExistParallel checks if multiple files exist in parallel
|
||||
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
||||
// Same implementation as PC version for consistency
|
||||
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
@@ -254,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
|
||||
// PreBuildISRCIndex pre-builds the ISRC index for a directory
|
||||
// Call this when app starts or when entering album/playlist screen
|
||||
func PreBuildISRCIndex(outputDir string) error {
|
||||
if outputDir == "" {
|
||||
return fmt.Errorf("output directory is required")
|
||||
@@ -265,8 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
||||
// This avoids rebuilding the entire index
|
||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||
if outputDir == "" || isrc == "" || filePath == "" {
|
||||
return
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension management functionality
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -15,8 +14,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// compareVersions compares two semantic version strings
|
||||
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||
func compareVersions(v1, v2 string) int {
|
||||
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||
@@ -46,24 +43,24 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// LoadedExtension represents an extension that has been loaded into memory
|
||||
type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `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"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
// ExtensionManager manages all loaded extensions
|
||||
type ExtensionManager struct {
|
||||
mu sync.RWMutex
|
||||
extensions map[string]*LoadedExtension
|
||||
extensionsDir string // Base directory for extensions
|
||||
dataDir string // Base directory for extension data
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -71,7 +68,6 @@ var (
|
||||
globalExtManagerOnce sync.Once
|
||||
)
|
||||
|
||||
// GetExtensionManager returns the global extension manager instance
|
||||
func GetExtensionManager() *ExtensionManager {
|
||||
globalExtManagerOnce.Do(func() {
|
||||
globalExtManager = &ExtensionManager{
|
||||
@@ -81,7 +77,6 @@ func GetExtensionManager() *ExtensionManager {
|
||||
return globalExtManager
|
||||
}
|
||||
|
||||
// SetDirectories sets the extensions and data directories
|
||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -99,14 +94,11 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadExtensionFromFile loads an extension from a .spotiflac-ext file
|
||||
func (m *ExtensionManager) LoadExtensionFromFile(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")
|
||||
}
|
||||
|
||||
// Open the zip 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")
|
||||
@@ -180,14 +172,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract all files (preserving directory structure)
|
||||
for _, file := range zipReader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve relative path within the zip (support subdirectories)
|
||||
// Clean the path to prevent path traversal attacks
|
||||
relPath := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||
@@ -232,7 +221,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
// Initialize Goja VM
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
@@ -245,7 +233,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// initializeVM creates and initializes the Goja VM for an extension
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
@@ -257,6 +244,7 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
ext.runtime = runtime
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
|
||||
@@ -280,13 +268,11 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// Run the extension code
|
||||
_, err = vm.RunString(string(jsCode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||
}
|
||||
|
||||
// Verify extension was registered
|
||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||
return fmt.Errorf("extension did not call registerExtension()")
|
||||
}
|
||||
@@ -294,7 +280,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnloadExtension unloads an extension by ID
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -304,9 +289,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
// Call cleanup if VM is initialized
|
||||
if ext.VM != nil {
|
||||
// Try to call cleanup function
|
||||
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)
|
||||
@@ -314,16 +297,20 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
||||
}
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
ext.runtime = nil
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
delete(m.extensions, extensionID)
|
||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExtension returns a loaded extension by ID
|
||||
// Returns error if extension not found (gomobile compatible)
|
||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -335,7 +322,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// GetAllExtensions returns all loaded extensions
|
||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
@@ -347,7 +333,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||
return result
|
||||
}
|
||||
|
||||
// SetExtensionEnabled enables or disables an extension
|
||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -360,7 +345,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
ext.Enabled = enabled
|
||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||
|
||||
// Persist enabled state to settings store
|
||||
store := GetExtensionSettingsStore()
|
||||
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
|
||||
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
|
||||
@@ -369,7 +353,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadExtensionsFromDirectory scans a directory and loads all valid extensions
|
||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||
var loaded []string
|
||||
var errors []error
|
||||
@@ -408,7 +391,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
return loaded, errors
|
||||
}
|
||||
|
||||
// loadExtensionFromDirectory loads an extension from an already extracted directory
|
||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -419,7 +401,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
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)
|
||||
@@ -457,7 +438,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Goja VM
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
@@ -470,42 +450,31 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// RemoveExtension completely removes an extension (unload + delete files)
|
||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
ext, err := m.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unload first
|
||||
if err := m.UnloadExtension(extensionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove source directory
|
||||
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
|
||||
}
|
||||
|
||||
// UpgradeExtension upgrades an existing extension from a new package file
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Open the zip 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")
|
||||
@@ -553,7 +522,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
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)
|
||||
@@ -564,16 +532,12 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
|
||||
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
|
||||
|
||||
// Cleanup and unload existing extension
|
||||
m.CleanupExtension(existing.ID)
|
||||
m.UnloadExtension(existing.ID)
|
||||
|
||||
// Remove old source files but keep data directory
|
||||
if extDir != "" {
|
||||
if err := os.RemoveAll(extDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
||||
@@ -628,7 +592,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
// Initialize Goja VM
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
@@ -644,7 +607,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// ExtensionUpgradeInfo holds information about extension upgrade check
|
||||
type ExtensionUpgradeInfo struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
@@ -653,16 +615,13 @@ type ExtensionUpgradeInfo struct {
|
||||
IsInstalled bool `json:"is_installed"`
|
||||
}
|
||||
|
||||
// checkExtensionUpgradeInternal checks if a package file is an upgrade for an existing extension
|
||||
// Internal function that returns struct
|
||||
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")
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
// Open the zip file
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file")
|
||||
}
|
||||
@@ -705,7 +664,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// Not installed - this is a new install, not upgrade
|
||||
info.CurrentVersion = ""
|
||||
info.CanUpgrade = false
|
||||
} else {
|
||||
@@ -716,7 +674,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON
|
||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||
if err != nil {
|
||||
@@ -731,32 +688,33 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter
|
||||
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"`
|
||||
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"`
|
||||
HasLyricsProvider bool `json:"has_lyrics_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))
|
||||
@@ -769,7 +727,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
permissions = append(permissions, "storage:enabled")
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "loaded"
|
||||
if ext.Error != "" {
|
||||
status = "error"
|
||||
@@ -809,10 +766,12 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Permissions: permissions,
|
||||
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
|
||||
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||
TrackMatching: ext.Manifest.TrackMatching,
|
||||
PostProcessing: ext.Manifest.PostProcessing,
|
||||
Capabilities: ext.Manifest.Capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,9 +783,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== Extension Lifecycle ====================
|
||||
|
||||
// InitializeExtension calls the extension's initialize method with settings
|
||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -888,7 +844,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExtension calls the extension's cleanup method
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -899,10 +854,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil // No VM, nothing to cleanup
|
||||
return nil
|
||||
}
|
||||
|
||||
// Call cleanup function
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
@@ -941,7 +895,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnloadAllExtensions unloads all extensions gracefully
|
||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
m.mu.Lock()
|
||||
extensionIDs := make([]string, 0, len(m.extensions))
|
||||
@@ -951,17 +904,12 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, id := range extensionIDs {
|
||||
// Call cleanup first
|
||||
m.CleanupExtension(id)
|
||||
// Then unload
|
||||
m.UnloadExtension(id)
|
||||
}
|
||||
|
||||
GoLog("[Extension] All extensions unloaded\n")
|
||||
}
|
||||
|
||||
// InvokeAction calls a custom action function on an extension (e.g., for button settings)
|
||||
// The function is called as extension.<actionName>() and can return a result
|
||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -979,7 +927,6 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
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') {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension manifest parsing and validation
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -7,15 +6,14 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExtensionType represents the type of extension
|
||||
type ExtensionType string
|
||||
|
||||
const (
|
||||
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
|
||||
)
|
||||
|
||||
// SettingType represents the type of a setting field
|
||||
type SettingType string
|
||||
|
||||
const (
|
||||
@@ -26,14 +24,12 @@ const (
|
||||
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||
)
|
||||
|
||||
// ExtensionPermissions defines what resources an extension can access
|
||||
type ExtensionPermissions struct {
|
||||
Network []string `json:"network"` // List of allowed domains
|
||||
Storage bool `json:"storage"` // Whether extension can use storage API
|
||||
File bool `json:"file"` // Whether extension can use file API
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
}
|
||||
|
||||
// ExtensionSetting defines a configurable setting for an extension
|
||||
type ExtensionSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
@@ -42,19 +38,17 @@ type ExtensionSetting struct {
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"` // For select type
|
||||
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
|
||||
Options []string `json:"options,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
// QualityOption represents a quality option for download providers
|
||||
type QualityOption struct {
|
||||
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128")
|
||||
Label string `json:"label"` // Display name (e.g., "MP3 320kbps")
|
||||
Description string `json:"description"` // Optional description (e.g., "Best quality MP3")
|
||||
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// QualitySpecificSetting represents a setting that's specific to a quality option
|
||||
type QualitySpecificSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
@@ -63,71 +57,72 @@ type QualitySpecificSetting struct {
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"` // For select type
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type SearchFilter struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
// SearchBehaviorConfig defines custom search behavior for an extension
|
||||
type SearchBehaviorConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension provides custom search
|
||||
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
|
||||
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
|
||||
Icon string `json:"icon,omitempty"` // Icon for search tab
|
||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
|
||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
||||
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"`
|
||||
}
|
||||
|
||||
// URLHandlerConfig defines custom URL handling for an extension
|
||||
type URLHandlerConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension handles URLs
|
||||
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
|
||||
Enabled bool `json:"enabled"`
|
||||
Patterns []string `json:"patterns,omitempty"`
|
||||
}
|
||||
|
||||
// TrackMatchingConfig defines custom track matching behavior
|
||||
type TrackMatchingConfig struct {
|
||||
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
|
||||
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom"
|
||||
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching
|
||||
CustomMatching bool `json:"customMatching"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
DurationTolerance int `json:"durationTolerance,omitempty"`
|
||||
}
|
||||
|
||||
// PostProcessingHook defines a post-processing hook
|
||||
type PostProcessingHook struct {
|
||||
ID string `json:"id"` // Unique identifier
|
||||
Name string `json:"name"` // Display name
|
||||
Description string `json:"description,omitempty"` // Description
|
||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default
|
||||
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"])
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
|
||||
SupportedFormats []string `json:"supportedFormats,omitempty"`
|
||||
}
|
||||
|
||||
// PostProcessingConfig defines post-processing capabilities
|
||||
type PostProcessingConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension provides post-processing
|
||||
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
|
||||
Enabled bool `json:"enabled"`
|
||||
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
// ExtensionManifest represents the manifest.json of an extension
|
||||
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"` // Icon filename (e.g., "icon.png")
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||
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"`
|
||||
}
|
||||
|
||||
// ManifestValidationError represents a validation error in the manifest
|
||||
type ManifestValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
@@ -137,7 +132,6 @@ func (e *ManifestValidationError) Error() string {
|
||||
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// ParseManifest parses and validates a manifest from JSON bytes
|
||||
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||
var manifest ExtensionManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
@@ -151,9 +145,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// Validate checks if the manifest has all required fields and valid values
|
||||
func (m *ExtensionManifest) Validate() error {
|
||||
// Check required fields
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||
}
|
||||
@@ -174,17 +166,15 @@ func (m *ExtensionManifest) Validate() error {
|
||||
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
||||
}
|
||||
|
||||
// Validate extension types
|
||||
for _, t := range m.Types {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
|
||||
return &ManifestValidationError{
|
||||
Field: "type",
|
||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
|
||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate settings if present
|
||||
for i, setting := range m.Settings {
|
||||
if strings.TrimSpace(setting.Key) == "" {
|
||||
return &ManifestValidationError{
|
||||
@@ -200,21 +190,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate setting type
|
||||
validTypes := map[SettingType]bool{
|
||||
SettingTypeString: true,
|
||||
SettingTypeNumber: true,
|
||||
SettingTypeBool: true,
|
||||
SettingTypeSelect: true,
|
||||
SettingTypeButton: true,
|
||||
}
|
||||
if !validTypes[setting.Type] {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].type", i),
|
||||
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
|
||||
}
|
||||
}
|
||||
|
||||
// Select type requires options
|
||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||
return &ManifestValidationError{
|
||||
@@ -223,7 +198,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Button type requires action
|
||||
if setting.Type == SettingTypeButton && setting.Action == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].action", i),
|
||||
@@ -235,7 +209,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasType checks if the extension has a specific type
|
||||
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||
for _, et := range m.Types {
|
||||
if et == t {
|
||||
@@ -245,17 +218,18 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsMetadataProvider returns true if extension provides metadata
|
||||
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||
return m.HasType(ExtensionTypeMetadataProvider)
|
||||
}
|
||||
|
||||
// IsDownloadProvider returns true if extension provides downloads
|
||||
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||
return m.HasType(ExtensionTypeDownloadProvider)
|
||||
}
|
||||
|
||||
// IsDomainAllowed checks if a domain is in the allowed network permissions
|
||||
func (m *ExtensionManifest) IsLyricsProvider() bool {
|
||||
return m.HasType(ExtensionTypeLyricsProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, allowed := range m.Permissions.Network {
|
||||
@@ -265,7 +239,7 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
}
|
||||
// Support wildcard subdomains (e.g., *.example.com)
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
suffix := allowed[1:] // Remove the *
|
||||
suffix := allowed[1:]
|
||||
if strings.HasSuffix(domain, suffix) {
|
||||
return true
|
||||
}
|
||||
@@ -274,37 +248,30 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// HasCustomSearch returns true if extension provides custom search
|
||||
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||
}
|
||||
|
||||
// HasCustomMatching returns true if extension provides custom track matching
|
||||
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||
}
|
||||
|
||||
// HasPostProcessing returns true if extension provides post-processing
|
||||
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||
}
|
||||
|
||||
// HasURLHandler returns true if extension handles custom URLs
|
||||
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||
}
|
||||
|
||||
// MatchesURL checks if a URL matches any of the extension's URL patterns
|
||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
if !m.HasURLHandler() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse URL to get host
|
||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||
for _, pattern := range m.URLHandler.Patterns {
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
// Check if URL contains the pattern (host match)
|
||||
if strings.Contains(urlStr, pattern) {
|
||||
return true
|
||||
}
|
||||
@@ -312,7 +279,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPostProcessingHooks returns all post-processing hooks
|
||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||
if m.PostProcessing == nil {
|
||||
return nil
|
||||
@@ -320,7 +286,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||
return m.PostProcessing.Hooks
|
||||
}
|
||||
|
||||
// ToJSON serializes the manifest to JSON
|
||||
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Package gobackend provides extension runtime with sandboxed execution
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -17,7 +19,6 @@ var (
|
||||
extensionAuthStateMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ExtensionAuthState holds auth state for an extension
|
||||
type ExtensionAuthState struct {
|
||||
PendingAuthURL string
|
||||
AuthCode string
|
||||
@@ -25,12 +26,10 @@ type ExtensionAuthState struct {
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
IsAuthenticated bool
|
||||
// PKCE support
|
||||
PKCEVerifier string
|
||||
PKCEChallenge string
|
||||
PKCEVerifier string
|
||||
PKCEChallenge string
|
||||
}
|
||||
|
||||
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
|
||||
type PendingAuthRequest struct {
|
||||
ExtensionID string
|
||||
AuthURL string
|
||||
@@ -42,7 +41,6 @@ var (
|
||||
pendingAuthRequestsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
|
||||
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||
pendingAuthRequestsMu.RLock()
|
||||
defer pendingAuthRequestsMu.RUnlock()
|
||||
@@ -55,7 +53,6 @@ func ClearPendingAuthRequest(extensionID string) {
|
||||
delete(pendingAuthRequests, extensionID)
|
||||
}
|
||||
|
||||
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
|
||||
func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
@@ -68,7 +65,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||
state.AuthCode = authCode
|
||||
}
|
||||
|
||||
// SetExtensionTokens sets access/refresh tokens for an extension
|
||||
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
@@ -84,7 +80,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
// ExtensionRuntime provides sandboxed APIs for extensions
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
@@ -93,50 +88,88 @@ type ExtensionRuntime struct {
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
storageLoaded bool
|
||||
storageDirty bool
|
||||
storageClosed bool
|
||||
storageTimer *time.Timer
|
||||
storageWriteMu sync.Mutex
|
||||
|
||||
credentialsMu sync.RWMutex
|
||||
credentialsCache map[string]interface{}
|
||||
credentialsLoaded bool
|
||||
storageFlushDelay time.Duration
|
||||
}
|
||||
|
||||
// NewExtensionRuntime creates a new runtime for an extension
|
||||
type privateIPCacheEntry struct {
|
||||
isPrivate bool
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
privateIPCacheTTL = 5 * time.Minute
|
||||
privateIPErrorCacheTTL = 30 * time.Second
|
||||
maxPrivateIPCacheSize = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
privateIPCache = make(map[string]privateIPCacheEntry)
|
||||
privateIPCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
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,
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
cookieJar: jar,
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
// Create HTTP client with redirect validation to prevent SSRF via open redirect
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// Validate redirect target domain against allowed domains
|
||||
domain := req.URL.Hostname()
|
||||
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain}
|
||||
}
|
||||
// Also block redirects to private/local networks (SSRF protection)
|
||||
if isPrivateIP(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||
}
|
||||
// Default redirect limit (10)
|
||||
if len(via) >= 10 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Transport: sharedTransport,
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
}
|
||||
client.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
|
||||
}
|
||||
|
||||
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
|
||||
type RedirectBlockedError struct {
|
||||
Domain string
|
||||
IsPrivate bool
|
||||
@@ -149,41 +182,102 @@ func (e *RedirectBlockedError) Error() string {
|
||||
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 {
|
||||
// Block common private network patterns
|
||||
// This is a simple check - for production, consider DNS resolution
|
||||
privatePatterns := []string{
|
||||
"localhost",
|
||||
"127.",
|
||||
"10.",
|
||||
"172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.",
|
||||
"172.24.", "172.25.", "172.26.", "172.27.",
|
||||
"172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"192.168.",
|
||||
"169.254.", // Link-local
|
||||
"::1", // IPv6 localhost
|
||||
"fc00:", // IPv6 private
|
||||
"fe80:", // IPv6 link-local
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
if hostLower == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
hostLower := host
|
||||
for _, pattern := range privatePatterns {
|
||||
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also block .local domains
|
||||
if len(host) > 6 && host[len(host)-6:] == ".local" {
|
||||
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(hostLower); ip != nil {
|
||||
return isPrivateIPAddr(ip)
|
||||
}
|
||||
|
||||
if cached, ok := getPrivateIPCache(hostLower); ok {
|
||||
return cached
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(hostLower)
|
||||
if err != nil {
|
||||
setPrivateIPCache(hostLower, false, privateIPErrorCacheTTL)
|
||||
return false
|
||||
}
|
||||
|
||||
isPrivate := false
|
||||
for _, ip := range ips {
|
||||
if isPrivateIPAddr(ip) {
|
||||
isPrivate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL)
|
||||
return isPrivate
|
||||
}
|
||||
|
||||
func getPrivateIPCache(host string) (bool, bool) {
|
||||
now := time.Now()
|
||||
|
||||
privateIPCacheMu.RLock()
|
||||
entry, exists := privateIPCache[host]
|
||||
privateIPCacheMu.RUnlock()
|
||||
if !exists {
|
||||
return false, false
|
||||
}
|
||||
|
||||
if now.Before(entry.expiresAt) {
|
||||
return entry.isPrivate, true
|
||||
}
|
||||
|
||||
privateIPCacheMu.Lock()
|
||||
delete(privateIPCache, host)
|
||||
privateIPCacheMu.Unlock()
|
||||
return false, false
|
||||
}
|
||||
|
||||
func setPrivateIPCache(host string, isPrivate bool, ttl time.Duration) {
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
|
||||
privateIPCacheMu.Lock()
|
||||
if len(privateIPCache) >= maxPrivateIPCacheSize {
|
||||
now := time.Now()
|
||||
for key, entry := range privateIPCache {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(privateIPCache, key)
|
||||
}
|
||||
}
|
||||
if len(privateIPCache) >= maxPrivateIPCacheSize {
|
||||
privateIPCache = make(map[string]privateIPCacheEntry)
|
||||
}
|
||||
}
|
||||
privateIPCache[host] = privateIPCacheEntry{
|
||||
isPrivate: isPrivate,
|
||||
expiresAt: expiresAt,
|
||||
}
|
||||
privateIPCacheMu.Unlock()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// simpleCookieJar is a simple in-memory cookie jar
|
||||
type simpleCookieJar struct {
|
||||
cookies map[string][]*http.Cookie
|
||||
mu sync.RWMutex
|
||||
@@ -208,34 +302,29 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
// SetSettings updates the runtime settings
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
// RegisterAPIs registers all sandboxed APIs to the Goja VM
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
// HTTP client (sandboxed to allowed domains)
|
||||
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) // Generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
httpObj.Set("request", r.httpRequest)
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
// Storage API
|
||||
storageObj := vm.NewObject()
|
||||
storageObj.Set("get", r.storageGet)
|
||||
storageObj.Set("set", r.storageSet)
|
||||
storageObj.Set("remove", r.storageRemove)
|
||||
vm.Set("storage", storageObj)
|
||||
|
||||
// Secure Credentials API (encrypted storage for sensitive data)
|
||||
credentialsObj := vm.NewObject()
|
||||
credentialsObj.Set("store", r.credentialsStore)
|
||||
credentialsObj.Set("get", r.credentialsGet)
|
||||
@@ -243,7 +332,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
credentialsObj.Set("has", r.credentialsHas)
|
||||
vm.Set("credentials", credentialsObj)
|
||||
|
||||
// Auth API (for OAuth and other auth flows)
|
||||
authObj := vm.NewObject()
|
||||
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||
authObj.Set("getAuthCode", r.authGetCode)
|
||||
@@ -251,14 +339,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
authObj.Set("clearAuth", r.authClear)
|
||||
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||
authObj.Set("getTokens", r.authGetTokens)
|
||||
// PKCE support
|
||||
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||
authObj.Set("getPKCE", r.authGetPKCE)
|
||||
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
// File operations (sandboxed)
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
@@ -270,21 +356,18 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
vm.Set("file", fileObj)
|
||||
|
||||
// FFmpeg API (for post-processing)
|
||||
ffmpegObj := vm.NewObject()
|
||||
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||
vm.Set("ffmpeg", ffmpegObj)
|
||||
|
||||
// Track matching API
|
||||
matchingObj := vm.NewObject()
|
||||
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
||||
vm.Set("matching", matchingObj)
|
||||
|
||||
// Utilities
|
||||
utilsObj := vm.NewObject()
|
||||
utilsObj.Set("base64Encode", r.base64Encode)
|
||||
utilsObj.Set("base64Decode", r.base64Decode)
|
||||
@@ -295,13 +378,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
||||
utilsObj.Set("parseJSON", r.parseJSON)
|
||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||
// Crypto utilities for developers
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
// Log object (already set in extension_manager.go, but we can enhance it)
|
||||
logObj := vm.NewObject()
|
||||
logObj.Set("debug", r.logDebug)
|
||||
logObj.Set("info", r.logInfo)
|
||||
@@ -309,27 +391,18 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
logObj.Set("error", r.logError)
|
||||
vm.Set("log", logObj)
|
||||
|
||||
// Go backend functions
|
||||
gobackendObj := vm.NewObject()
|
||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
|
||||
// ==================== Browser-like Polyfills ====================
|
||||
// These make porting browser/Node.js libraries easier
|
||||
|
||||
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
||||
vm.Set("fetch", r.fetchPolyfill)
|
||||
|
||||
// Global atob/btoa - Base64 encoding (browser-compatible)
|
||||
vm.Set("atob", r.atobPolyfill)
|
||||
vm.Set("btoa", r.btoaPolyfill)
|
||||
|
||||
// TextEncoder/TextDecoder constructors
|
||||
r.registerTextEncoderDecoder(vm)
|
||||
|
||||
// URL class for URL parsing
|
||||
r.registerURLClass(vm)
|
||||
|
||||
// JSON global (browser-compatible)
|
||||
r.registerJSONGlobal(vm)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Auth API and PKCE support for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -16,9 +15,43 @@ import (
|
||||
"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)
|
||||
}
|
||||
|
||||
// authOpenUrl requests Flutter to open an OAuth URL
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -33,7 +66,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
// Store pending auth request for Flutter to pick up
|
||||
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,
|
||||
@@ -42,7 +81,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
// Update auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -50,10 +88,10 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PendingAuthURL = authURL
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -61,7 +99,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// authGetCode gets the auth code (set by Flutter after OAuth callback)
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -74,13 +111,11 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(state.AuthCode)
|
||||
}
|
||||
|
||||
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
|
||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// Can accept either just auth code or an object with tokens
|
||||
arg := call.Arguments[0].Export()
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
@@ -114,7 +149,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authClear clears all auth state for the extension
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
@@ -128,7 +162,6 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authIsAuthenticated checks if extension has valid auth
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -138,7 +171,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -146,7 +178,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
// authGetTokens returns current tokens (for extension to use in API calls)
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -170,9 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// ==================== PKCE Support ====================
|
||||
|
||||
// generatePKCEVerifier generates a cryptographically random code verifier
|
||||
// Length should be between 43-128 characters (RFC 7636)
|
||||
func generatePKCEVerifier(length int) (string, error) {
|
||||
if length < 43 {
|
||||
@@ -182,16 +210,13 @@ func generatePKCEVerifier(length int) (string, error) {
|
||||
length = 128
|
||||
}
|
||||
|
||||
// Generate random bytes
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use base64url encoding without padding (RFC 7636 compliant)
|
||||
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
// Trim to exact length
|
||||
if len(verifier) > length {
|
||||
verifier = verifier[:length]
|
||||
}
|
||||
@@ -199,17 +224,13 @@ func generatePKCEVerifier(length int) (string, error) {
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
// generatePKCEChallenge generates a code challenge from verifier using S256 method
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// authGeneratePKCE generates a PKCE code verifier and challenge pair
|
||||
// Returns: { verifier: string, challenge: string, method: "S256" }
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
// Default length is 64 characters
|
||||
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 {
|
||||
@@ -227,7 +248,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store in auth state for later use
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -247,7 +267,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -264,9 +283,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
// Returns: { success, authUrl, pkce: { verifier, challenge } }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -284,7 +301,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
authURL, _ := config["authUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
@@ -295,12 +311,16 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
"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(),
|
||||
})
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
|
||||
// Generate PKCE
|
||||
verifier, err := generatePKCEVerifier(64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -310,7 +330,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store PKCE in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -319,10 +338,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
// Build OAuth URL with PKCE parameters
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -342,7 +360,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
query.Set("scope", scope)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
for k, v := range extraParams {
|
||||
query.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
@@ -350,7 +367,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
fullAuthURL := parsedURL.String()
|
||||
|
||||
// Store pending auth request for Flutter
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
@@ -359,7 +375,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -372,9 +388,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
})
|
||||
}
|
||||
|
||||
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
|
||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||
// Uses the stored PKCE verifier automatically
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -392,7 +406,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
tokenURL, _ := config["tokenUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
@@ -405,7 +418,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored PKCE verifier
|
||||
extensionAuthStateMu.RLock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
var verifier string
|
||||
@@ -421,7 +433,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(tokenURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -429,7 +440,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Build token request body
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", clientID)
|
||||
@@ -439,14 +449,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||
for k, v := range extraParams {
|
||||
formData.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// Make token request
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -474,18 +482,20 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||
if len(bodyPreview) > 1000 {
|
||||
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||
}
|
||||
|
||||
// Parse response
|
||||
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": string(body),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
// Check for error in response
|
||||
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||
errDesc, _ := tokenResp["error_description"].(string)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -495,7 +505,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Extract tokens
|
||||
accessToken, _ := tokenResp["access_token"].(string)
|
||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
@@ -504,11 +513,10 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no access_token in response",
|
||||
"body": string(body),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
// Store tokens in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists = extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -521,14 +529,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
if expiresIn > 0 {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
// Clear PKCE after successful exchange
|
||||
state.PKCEVerifier = ""
|
||||
state.PKCEChallenge = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||
|
||||
// Return full token response
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"access_token": accessToken,
|
||||
@@ -538,7 +544,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
if expiresIn > 0 {
|
||||
result["expires_in"] = expiresIn
|
||||
}
|
||||
// Include any additional fields from response
|
||||
if scope, ok := tokenResp["scope"].(string); ok {
|
||||
result["scope"] = scope
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides FFmpeg API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -10,9 +9,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== FFmpeg API (Post-Processing) ====================
|
||||
|
||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute.
|
||||
type FFmpegCommand struct {
|
||||
ExtensionID string
|
||||
Command string
|
||||
@@ -24,21 +21,18 @@ type FFmpegCommand struct {
|
||||
Output string
|
||||
}
|
||||
|
||||
// Global FFmpeg command queue
|
||||
var (
|
||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||
ffmpegCommandsMu sync.RWMutex
|
||||
ffmpegCommandID int64
|
||||
)
|
||||
|
||||
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
|
||||
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||
ffmpegCommandsMu.RLock()
|
||||
defer ffmpegCommandsMu.RUnlock()
|
||||
return ffmpegCommands[commandID]
|
||||
}
|
||||
|
||||
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
|
||||
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
@@ -50,14 +44,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str
|
||||
}
|
||||
}
|
||||
|
||||
// ClearFFmpegCommand removes a completed FFmpeg command
|
||||
func ClearFFmpegCommand(commandID string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
// ffmpegExecute queues an FFmpeg command for execution by Flutter
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -68,7 +60,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
|
||||
command := call.Arguments[0].String()
|
||||
|
||||
// Generate unique command ID
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommandID++
|
||||
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||
@@ -81,7 +72,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
|
||||
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||
|
||||
// Wait for completion (with timeout)
|
||||
timeout := 5 * time.Minute
|
||||
start := time.Now()
|
||||
for {
|
||||
@@ -101,7 +91,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
// Cleanup
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
@@ -118,7 +107,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpegGetInfo gets audio file information using FFprobe
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -129,7 +117,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
|
||||
// Use Go's built-in audio quality function
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -147,7 +134,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// ffmpegConvert is a helper for common conversion operations
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -159,7 +145,6 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
inputPath := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Get options if provided
|
||||
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 {
|
||||
@@ -167,36 +152,29 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Build FFmpeg command
|
||||
var cmdParts []string
|
||||
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||
|
||||
// Audio codec
|
||||
if codec, ok := options["codec"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-c:a", codec)
|
||||
}
|
||||
|
||||
// Bitrate
|
||||
if bitrate, ok := options["bitrate"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||
}
|
||||
|
||||
// Sample rate
|
||||
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||
}
|
||||
|
||||
// Channels
|
||||
if channels, ok := options["channels"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||
}
|
||||
|
||||
// Overwrite output
|
||||
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||
|
||||
command := strings.Join(cmdParts, " ")
|
||||
|
||||
// Execute via ffmpegExecute
|
||||
execCall := goja.FunctionCall{
|
||||
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides File API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -13,16 +12,11 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== File API (Sandboxed) ====================
|
||||
|
||||
// List of allowed directories for file operations (set by Go backend for download operations)
|
||||
var (
|
||||
allowedDownloadDirs []string
|
||||
allowedDownloadDirsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
|
||||
// This should be called by the Go backend when setting up download paths
|
||||
func SetAllowedDownloadDirs(dirs []string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
@@ -30,7 +24,6 @@ func SetAllowedDownloadDirs(dirs []string) {
|
||||
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
||||
}
|
||||
|
||||
// AddAllowedDownloadDir adds a directory to the allowed list
|
||||
func AddAllowedDownloadDir(dir string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
@@ -40,68 +33,79 @@ func AddAllowedDownloadDir(dir string) {
|
||||
}
|
||||
}
|
||||
|
||||
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
|
||||
func isPathInAllowedDirs(absPath string) bool {
|
||||
allowedDownloadDirsMu.RLock()
|
||||
defer allowedDownloadDirsMu.RUnlock()
|
||||
|
||||
for _, allowedDir := range allowedDownloadDirs {
|
||||
if strings.HasPrefix(absPath, allowedDir) {
|
||||
if isPathWithinBase(allowedDir, absPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validatePath checks if the path is within the extension's sandbox
|
||||
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
|
||||
// Extensions should use relative paths for their own data storage
|
||||
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) {
|
||||
// Check if extension has file permission
|
||||
if !r.manifest.Permissions.File {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
|
||||
// Clean and resolve the path
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// SECURITY: Block absolute paths by default
|
||||
// Only allow if path is in explicitly allowed download directories
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
absPath, err := filepath.Abs(cleanPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Check if path is in allowed download directories
|
||||
if isPathInAllowedDirs(absPath) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// Block all other absolute paths
|
||||
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
||||
}
|
||||
|
||||
// For relative paths, join with data directory (extension's sandbox)
|
||||
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||
|
||||
// Resolve to absolute path
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Ensure path is within data directory (prevent path traversal)
|
||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||
if !strings.HasPrefix(absPath, absDataDir) {
|
||||
if !isPathWithinBase(absDataDir, absPath) {
|
||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||
}
|
||||
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// fileDownload downloads a file from URL to the specified path
|
||||
// Supports progress callback via options.onProgress
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -113,7 +117,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
urlStr := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -121,7 +124,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Validate output path (allows absolute paths for download queue)
|
||||
fullPath, err := r.validatePath(outputPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -130,20 +132,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get options if provided
|
||||
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 {
|
||||
// Extract headers
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Extract onProgress callback
|
||||
if progressVal, ok := opts["onProgress"]; ok {
|
||||
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||
onProgress = callable
|
||||
@@ -152,7 +151,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -161,7 +159,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -170,7 +167,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
@@ -178,7 +174,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
// Download file
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -195,7 +190,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create output file
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -205,12 +199,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get content length for progress
|
||||
contentLength := resp.ContentLength
|
||||
|
||||
// Copy content with progress reporting
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024) // 32KB buffer
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
@@ -235,7 +227,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if onProgress != nil && contentLength > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||
}
|
||||
@@ -260,7 +251,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists in the sandbox
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -276,7 +266,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
// fileDelete deletes a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -306,7 +295,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileRead reads a file from the sandbox
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -338,7 +326,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileWrite writes data to a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -358,7 +345,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -380,7 +366,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileCopy copies a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -408,16 +393,15 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Read source file
|
||||
data, err := os.ReadFile(fullSrc)
|
||||
srcFile, err := os.Open(fullSrc)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read source: %v", err),
|
||||
})
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -426,11 +410,26 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Write to destination
|
||||
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||
dstFile, err := os.OpenFile(fullDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write destination: %v", err),
|
||||
"error": fmt.Sprintf("failed to open destination: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
_ = dstFile.Close()
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to copy file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := dstFile.Close(); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to finalize destination: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -440,7 +439,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileMove moves/renames a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -468,7 +466,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -490,7 +487,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileGetSize returns the size of a file in bytes
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides HTTP API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -12,25 +11,33 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== HTTP API (Sandboxed) ====================
|
||||
|
||||
// HTTPResponse represents the response from an HTTP request
|
||||
type HTTPResponse struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
// validateDomain checks if the domain is allowed by the extension's permissions
|
||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
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")
|
||||
}
|
||||
|
||||
// Block private/local network access (SSRF protection)
|
||||
if isPrivateIP(domain) {
|
||||
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||
}
|
||||
@@ -42,7 +49,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpGet performs a GET request (sandboxed)
|
||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -52,7 +58,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -60,7 +65,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
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()
|
||||
@@ -71,7 +75,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -79,16 +82,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set default User-Agent if not provided by extension
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -97,7 +98,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -105,26 +105,24 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpPost performs a POST request (sandboxed)
|
||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -134,7 +132,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -142,7 +139,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get body if provided - support both string and object
|
||||
var bodyStr string
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
@@ -150,7 +146,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -159,12 +154,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
// Fallback to string conversion
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
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()
|
||||
@@ -175,7 +168,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -183,11 +175,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set defaults if not provided by extension
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
@@ -195,7 +186,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -204,7 +194,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -212,27 +201,24 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
// Usage: http.request(url, options) where options = { method, body, headers }
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -242,7 +228,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -250,27 +235,22 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Default options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// Parse options if provided
|
||||
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 {
|
||||
// Get method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Get body - support both string and object
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -283,7 +263,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
@@ -292,7 +271,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
@@ -305,11 +283,10 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set defaults if not provided by extension
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
@@ -317,7 +294,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -326,7 +302,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -334,43 +309,36 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Return response with helper properties
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
|
||||
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -380,7 +348,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -391,9 +358,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
|
||||
if method == "DELETE" {
|
||||
// http.delete(url, headers)
|
||||
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 {
|
||||
@@ -403,7 +368,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// http.put(url, body, headers) / http.patch(url, body, headers)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
@@ -432,7 +396,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
@@ -445,7 +408,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
@@ -456,7 +418,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -465,7 +426,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -473,7 +433,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
@@ -492,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
})
|
||||
}
|
||||
|
||||
// httpClearCookies clears all cookies for this extension
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Track Matching API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -7,9 +6,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Track Matching API ====================
|
||||
|
||||
// matchingCompareStrings compares two strings with fuzzy matching
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
@@ -22,12 +18,10 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
||||
return r.vm.ToValue(1.0)
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance-based similarity
|
||||
similarity := calculateStringSimilarity(str1, str2)
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
// matchingCompareDuration compares two durations with tolerance
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -36,8 +30,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
dur1 := int(call.Arguments[0].ToInteger())
|
||||
dur2 := int(call.Arguments[1].ToInteger())
|
||||
|
||||
// Default tolerance: 3 seconds
|
||||
tolerance := 3000 // milliseconds
|
||||
tolerance := 3000
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||
tolerance = int(call.Arguments[2].ToInteger())
|
||||
}
|
||||
@@ -50,7 +43,6 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
// matchingNormalizeString normalizes a string for comparison
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -61,7 +53,6 @@ func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(normalized)
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates similarity between two strings (0-1)
|
||||
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if len(s1) == 0 && len(s2) == 0 {
|
||||
return 1.0
|
||||
@@ -70,7 +61,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Use Levenshtein distance
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
maxLen := len(s1)
|
||||
if len(s2) > maxLen {
|
||||
@@ -80,7 +70,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
@@ -89,7 +78,6 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
// Create matrix
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
@@ -99,7 +87,6 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 1
|
||||
@@ -107,9 +94,9 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
cost = 0
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1, // deletion
|
||||
matrix[i][j-1]+1, // insertion
|
||||
matrix[i-1][j-1]+cost, // substitution
|
||||
matrix[i-1][j]+1,
|
||||
matrix[i][j-1]+1,
|
||||
matrix[i-1][j-1]+cost,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -117,12 +104,9 @@ func levenshteinDistance(s1, s2 string) int {
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
// normalizeStringForMatching normalizes a string for comparison
|
||||
func normalizeStringForMatching(s string) string {
|
||||
// Convert to lowercase
|
||||
s = strings.ToLower(s)
|
||||
|
||||
// Remove common suffixes/prefixes
|
||||
suffixes := []string{
|
||||
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||
@@ -136,7 +120,6 @@ func normalizeStringForMatching(s string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove special characters
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||
@@ -144,7 +127,6 @@ func normalizeStringForMatching(s string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse multiple spaces
|
||||
s = strings.Join(strings.Fields(result.String()), " ")
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Browser-like Polyfills for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -13,26 +12,21 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Browser-like Polyfills ====================
|
||||
// These polyfills make porting browser/Node.js libraries easier
|
||||
// without compromising sandbox security
|
||||
// without compromising sandbox security.
|
||||
|
||||
// fetchPolyfill implements browser-compatible fetch() API
|
||||
// Returns a Promise-like object with json(), text() methods
|
||||
// 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()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Parse options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
@@ -40,7 +34,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
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 {
|
||||
// Method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
@@ -61,7 +54,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Headers
|
||||
if h, ok := opts["headers"]; ok && h != nil {
|
||||
switch hv := h.(type) {
|
||||
case map[string]interface{}:
|
||||
@@ -73,7 +65,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
@@ -84,11 +75,9 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Set defaults if not provided
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
@@ -96,20 +85,17 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
@@ -119,7 +105,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create Response object (browser-compatible)
|
||||
responseObj := r.vm.NewObject()
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
@@ -127,15 +112,12 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
responseObj.Set("headers", respHeaders)
|
||||
responseObj.Set("url", urlStr)
|
||||
|
||||
// Store body for methods
|
||||
bodyString := string(body)
|
||||
|
||||
// text() method - returns body as string
|
||||
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(bodyString)
|
||||
})
|
||||
|
||||
// json() method - parses body as JSON
|
||||
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
@@ -145,9 +127,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
})
|
||||
|
||||
// arrayBuffer() method - returns body as array (simplified)
|
||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||
// Return as array of bytes
|
||||
byteArray := make([]interface{}, len(body))
|
||||
for i, b := range body {
|
||||
byteArray[i] = int(b)
|
||||
@@ -158,7 +138,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return responseObj
|
||||
}
|
||||
|
||||
// createFetchError creates a fetch error response
|
||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
errorObj := r.vm.NewObject()
|
||||
errorObj.Set("ok", false)
|
||||
@@ -174,7 +153,6 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
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("")
|
||||
@@ -182,7 +160,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
// Try URL-safe base64
|
||||
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||
@@ -192,7 +169,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
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("")
|
||||
@@ -201,14 +177,11 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
// TextEncoder constructor
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
encoder.Set("encoding", "utf-8")
|
||||
|
||||
// encode() method - string to Uint8Array
|
||||
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]byte{})
|
||||
@@ -216,7 +189,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
input := call.Arguments[0].String()
|
||||
bytes := []byte(input)
|
||||
|
||||
// Return as array (Uint8Array-like)
|
||||
result := make([]interface{}, len(bytes))
|
||||
for i, b := range bytes {
|
||||
result[i] = int(b)
|
||||
@@ -224,7 +196,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
|
||||
// encodeInto() method
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
// Simplified implementation
|
||||
if len(call.Arguments) < 2 {
|
||||
@@ -240,11 +211,9 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
return nil
|
||||
})
|
||||
|
||||
// TextDecoder constructor
|
||||
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
decoder := call.This
|
||||
|
||||
// Get encoding from arguments (default: utf-8)
|
||||
encoding := "utf-8"
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
encoding = call.Arguments[0].String()
|
||||
@@ -253,13 +222,11 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
decoder.Set("fatal", false)
|
||||
decoder.Set("ignoreBOM", false)
|
||||
|
||||
// decode() method - Uint8Array to string
|
||||
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
// Handle different input types
|
||||
input := call.Arguments[0].Export()
|
||||
var bytes []byte
|
||||
|
||||
@@ -279,7 +246,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
}
|
||||
}
|
||||
case string:
|
||||
// Already a string, just return it
|
||||
return vm.ToValue(v)
|
||||
default:
|
||||
return vm.ToValue("")
|
||||
@@ -292,7 +258,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
// registerURLClass registers the URL class for URL parsing
|
||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||
urlObj := call.This
|
||||
@@ -304,7 +269,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Handle relative URLs with base
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
baseStr := call.Arguments[1].String()
|
||||
baseURL, err := url.Parse(baseStr)
|
||||
@@ -322,7 +286,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set URL properties
|
||||
urlObj.Set("href", parsed.String())
|
||||
urlObj.Set("protocol", parsed.Scheme+":")
|
||||
urlObj.Set("host", parsed.Host)
|
||||
@@ -342,10 +305,9 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
password, _ := parsed.User.Password()
|
||||
urlObj.Set("password", password)
|
||||
|
||||
// searchParams object
|
||||
searchParams := vm.NewObject()
|
||||
queryValues := parsed.Query()
|
||||
|
||||
searchParams := vm.NewObject()
|
||||
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
@@ -379,12 +341,10 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
|
||||
urlObj.Set("searchParams", searchParams)
|
||||
|
||||
// toString method
|
||||
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
// toJSON method
|
||||
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
@@ -392,17 +352,14 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
return nil
|
||||
})
|
||||
|
||||
// URLSearchParams constructor
|
||||
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||
paramsObj := call.This
|
||||
values := url.Values{}
|
||||
|
||||
// Parse initial value if provided
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
init := call.Arguments[0].Export()
|
||||
switch v := init.(type) {
|
||||
case string:
|
||||
// Parse query string
|
||||
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||
values = parsed
|
||||
case map[string]interface{}:
|
||||
@@ -465,13 +422,8 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
// registerJSONGlobal ensures JSON global is properly set up
|
||||
// JSON is already built-in to Goja; this ensures a fallback exists.
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
// JSON is already built-in to Goja, but we can enhance it
|
||||
// This ensures JSON.parse and JSON.stringify work as expected
|
||||
|
||||
// The built-in JSON object should already work, but let's verify
|
||||
// and add any missing functionality if needed
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Storage and Credentials API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -11,48 +10,164 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Storage API ====================
|
||||
const (
|
||||
defaultStorageFlushDelay = 400 * time.Millisecond
|
||||
storageFlushRetryDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// getStoragePath returns the path to the extension's storage file
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
// loadStorage loads the storage data from disk
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
||||
if len(src) == 0 {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
||||
r.storageMu.RLock()
|
||||
if r.storageLoaded {
|
||||
r.storageMu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
r.storageMu.RUnlock()
|
||||
|
||||
r.storageMu.Lock()
|
||||
defer r.storageMu.Unlock()
|
||||
if r.storageLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
r.storageCache = make(map[string]interface{})
|
||||
r.storageLoaded = true
|
||||
return nil
|
||||
}
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
var storage map[string]interface{}
|
||||
if err := json.Unmarshal(data, &storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if storage == nil {
|
||||
storage = make(map[string]interface{})
|
||||
}
|
||||
|
||||
r.storageCache = storage
|
||||
r.storageLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
r.storageMu.RLock()
|
||||
defer r.storageMu.RUnlock()
|
||||
return cloneInterfaceMap(r.storageCache), nil
|
||||
}
|
||||
|
||||
// saveStorage saves the storage data to disk
|
||||
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := json.MarshalIndent(storage, "", " ")
|
||||
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
if r.storageClosed {
|
||||
return
|
||||
}
|
||||
if r.storageTimer != nil {
|
||||
return
|
||||
}
|
||||
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||
data, err := json.Marshal(storage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(storagePath, data, 0644)
|
||||
r.storageWriteMu.Lock()
|
||||
defer r.storageWriteMu.Unlock()
|
||||
|
||||
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
|
||||
if err := r.flushStorageDirty(); err != nil {
|
||||
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirty() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageTimer = nil
|
||||
r.storageMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if !r.storageDirty {
|
||||
r.storageTimer = nil
|
||||
r.storageMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
snapshot := cloneInterfaceMap(r.storageCache)
|
||||
r.storageDirty = false
|
||||
r.storageTimer = nil
|
||||
r.storageMu.Unlock()
|
||||
|
||||
if err := r.persistStorageSnapshot(snapshot); err != nil {
|
||||
r.storageMu.Lock()
|
||||
r.storageDirty = true
|
||||
r.queueStorageFlushLocked(storageFlushRetryDelay)
|
||||
r.storageMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageNow() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageTimer != nil {
|
||||
r.storageTimer.Stop()
|
||||
r.storageTimer = nil
|
||||
}
|
||||
if !r.storageLoaded || r.storageClosed {
|
||||
r.storageMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
snapshot := cloneInterfaceMap(r.storageCache)
|
||||
r.storageDirty = false
|
||||
r.storageMu.Unlock()
|
||||
|
||||
return r.persistStorageSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) closeStorageFlusher() {
|
||||
r.storageMu.Lock()
|
||||
r.storageClosed = true
|
||||
r.storageDirty = false
|
||||
if r.storageTimer != nil {
|
||||
r.storageTimer.Stop()
|
||||
r.storageTimer = nil
|
||||
}
|
||||
r.storageMu.Unlock()
|
||||
}
|
||||
|
||||
// storageGet retrieves a value from storage
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
@@ -60,15 +175,15 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := storage[key]
|
||||
r.storageMu.RLock()
|
||||
value, exists := r.storageCache[key]
|
||||
r.storageMu.RUnlock()
|
||||
if !exists {
|
||||
// Return default value if provided
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
@@ -78,7 +193,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
// storageSet stores a value in storage
|
||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -87,23 +201,30 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
if err := r.ensureStorageLoaded(); 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)
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
if existing, exists := r.storageCache[key]; exists {
|
||||
if reflect.DeepEqual(existing, value) {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
}
|
||||
r.storageCache[key] = value
|
||||
r.storageDirty = true
|
||||
r.queueStorageFlushLocked(r.storageFlushDelay)
|
||||
r.storageMu.Unlock()
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// storageRemove removes a value from storage
|
||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -111,51 +232,49 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
if err := r.ensureStorageLoaded(); 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)
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
if _, exists := r.storageCache[key]; !exists {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
delete(r.storageCache, key)
|
||||
r.storageDirty = true
|
||||
r.queueStorageFlushLocked(r.storageFlushDelay)
|
||||
r.storageMu.Unlock()
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// ==================== Credentials API (Encrypted Storage) ====================
|
||||
|
||||
// getCredentialsPath returns the path to the extension's encrypted credentials file
|
||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||
}
|
||||
|
||||
// getSaltPath returns the path to the extension's encryption salt file
|
||||
func (r *ExtensionRuntime) getSaltPath() string {
|
||||
return filepath.Join(r.dataDir, ".cred_salt")
|
||||
}
|
||||
|
||||
// getOrCreateSalt gets existing salt or creates a new random one
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
// Try to read existing salt
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
if err == nil && len(salt) == 32 {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
// Generate new random salt (32 bytes)
|
||||
salt = make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
// Save salt to file
|
||||
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||
}
|
||||
@@ -163,58 +282,80 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
// getEncryptionKey derives an encryption key from extension ID + random salt
|
||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
// Get or create per-installation random salt
|
||||
salt, err := r.getOrCreateSalt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Combine extension ID + random salt for key derivation
|
||||
// This makes each installation unique, preventing mass decryption attacks
|
||||
combined := append([]byte(r.extensionID), salt...)
|
||||
hash := sha256.Sum256(combined)
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
// loadCredentials loads and decrypts credentials from disk
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
||||
r.credentialsMu.RLock()
|
||||
if r.credentialsLoaded {
|
||||
r.credentialsMu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
r.credentialsMu.RUnlock()
|
||||
|
||||
r.credentialsMu.Lock()
|
||||
defer r.credentialsMu.Unlock()
|
||||
if r.credentialsLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
data, err := os.ReadFile(credPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
r.credentialsCache = make(map[string]interface{})
|
||||
r.credentialsLoaded = true
|
||||
return nil
|
||||
}
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||
return 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)
|
||||
return fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
var creds map[string]interface{}
|
||||
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||
return err
|
||||
}
|
||||
if creds == nil {
|
||||
creds = make(map[string]interface{})
|
||||
}
|
||||
|
||||
r.credentialsCache = creds
|
||||
r.credentialsLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
r.credentialsMu.RLock()
|
||||
defer r.credentialsMu.RUnlock()
|
||||
return cloneInterfaceMap(r.credentialsCache), nil
|
||||
}
|
||||
|
||||
// saveCredentials encrypts and saves credentials to disk
|
||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
@@ -225,10 +366,17 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions
|
||||
if err := os.WriteFile(credPath, encrypted, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.credentialsMu.Lock()
|
||||
r.credentialsCache = cloneInterfaceMap(creds)
|
||||
r.credentialsLoaded = true
|
||||
r.credentialsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// credentialsStore stores an encrypted credential
|
||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -240,8 +388,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -249,9 +396,12 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
creds[key] = value
|
||||
r.credentialsMu.RLock()
|
||||
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||
r.credentialsMu.RUnlock()
|
||||
nextCreds[key] = value
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
if err := r.saveCredentials(nextCreds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -264,7 +414,6 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// credentialsGet retrieves a decrypted credential
|
||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
@@ -272,15 +421,15 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := creds[key]
|
||||
r.credentialsMu.RLock()
|
||||
value, exists := r.credentialsCache[key]
|
||||
r.credentialsMu.RUnlock()
|
||||
if !exists {
|
||||
// Return default value if provided
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
@@ -290,7 +439,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
// credentialsRemove removes a credential
|
||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -298,15 +446,17 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
delete(creds, key)
|
||||
r.credentialsMu.RLock()
|
||||
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||
r.credentialsMu.RUnlock()
|
||||
delete(nextCreds, key)
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
if err := r.saveCredentials(nextCreds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -314,7 +464,6 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// credentialsHas checks if a credential exists
|
||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -322,18 +471,16 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
_, exists := creds[key]
|
||||
r.credentialsMu.RLock()
|
||||
_, exists := r.credentialsCache[key]
|
||||
r.credentialsMu.RUnlock()
|
||||
return r.vm.ToValue(exists)
|
||||
}
|
||||
|
||||
// ==================== Crypto Utilities ====================
|
||||
|
||||
// encryptAES encrypts data using AES-GCM
|
||||
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
@@ -354,7 +501,6 @@ func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// decryptAES decrypts data using AES-GCM
|
||||
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
|
||||
t.Helper()
|
||||
result := runtime.storageSet(goja.FunctionCall{
|
||||
Arguments: []goja.Value{
|
||||
runtime.vm.ToValue(key),
|
||||
runtime.vm.ToValue(value),
|
||||
},
|
||||
})
|
||||
if !result.ToBoolean() {
|
||||
t.Fatalf("storage.set(%q) returned false", key)
|
||||
}
|
||||
}
|
||||
|
||||
func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read storage file: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal storage file: %v", err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "storage-test",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||
runtime.RegisterAPIs(goja.New())
|
||||
|
||||
setStorageValue(t, runtime, "k1", "v1")
|
||||
setStorageValue(t, runtime, "k2", 2)
|
||||
|
||||
storagePath := filepath.Join(ext.DataDir, "storage.json")
|
||||
deadline := time.Now().Add(1500 * time.Millisecond)
|
||||
|
||||
var raw []byte
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err == nil {
|
||||
raw = data
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
t.Fatalf("storage.json was not written within timeout")
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal storage file: %v", err)
|
||||
}
|
||||
if parsed["k1"] != "v1" {
|
||||
t.Fatalf("expected k1=v1, got %v", parsed["k1"])
|
||||
}
|
||||
if parsed["k2"] != float64(2) {
|
||||
t.Fatalf("expected k2=2, got %v", parsed["k2"])
|
||||
}
|
||||
if bytes.Contains(raw, []byte("\n")) {
|
||||
t.Fatalf("expected compact JSON without indentation, got: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "unload-storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "unload-storage-test",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
VM: goja.New(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = time.Hour
|
||||
runtime.RegisterAPIs(ext.VM)
|
||||
ext.runtime = runtime
|
||||
|
||||
manager := &ExtensionManager{
|
||||
extensions: map[string]*LoadedExtension{
|
||||
ext.ID: ext,
|
||||
},
|
||||
}
|
||||
|
||||
setStorageValue(t, runtime, "persist_on_unload", true)
|
||||
|
||||
if err := manager.UnloadExtension(ext.ID); err != nil {
|
||||
t.Fatalf("UnloadExtension failed: %v", err)
|
||||
}
|
||||
|
||||
storagePath := filepath.Join(ext.DataDir, "storage.json")
|
||||
parsed := readStorageMap(t, storagePath)
|
||||
if parsed["persist_on_unload"] != true {
|
||||
t.Fatalf("expected pending storage value to be flushed on unload, got %v", parsed["persist_on_unload"])
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Utility functions for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -12,13 +11,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Utility Functions ====================
|
||||
|
||||
// base64Encode encodes a string to base64
|
||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -27,7 +24,6 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
// base64Decode decodes a base64 string
|
||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -40,7 +36,6 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
// md5Hash computes MD5 hash of a string
|
||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -50,7 +45,6 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
// sha256Hash computes SHA256 hash of a string
|
||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -60,7 +54,6 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
// hmacSHA256 computes HMAC-SHA256 of a message with a key
|
||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -73,7 +66,6 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
|
||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -86,15 +78,11 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
|
||||
// Arguments: message (string or array of bytes), key (string or array of bytes)
|
||||
// Returns: array of bytes (for TOTP dynamic truncation)
|
||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get key - can be string or array of bytes
|
||||
var keyBytes []byte
|
||||
keyArg := call.Arguments[0].Export()
|
||||
switch k := keyArg.(type) {
|
||||
@@ -113,7 +101,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get message - can be string or array of bytes
|
||||
var msgBytes []byte
|
||||
msgArg := call.Arguments[1].Export()
|
||||
switch m := msgArg.(type) {
|
||||
@@ -136,7 +123,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
mac.Write(msgBytes)
|
||||
result := mac.Sum(nil)
|
||||
|
||||
// Convert to array of numbers for JavaScript
|
||||
jsArray := make([]interface{}, len(result))
|
||||
for i, b := range result {
|
||||
jsArray[i] = int(b)
|
||||
@@ -144,7 +130,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(jsArray)
|
||||
}
|
||||
|
||||
// parseJSON parses a JSON string
|
||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
@@ -160,7 +145,6 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// stringifyJSON converts a value to JSON string
|
||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -176,9 +160,6 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(data))
|
||||
}
|
||||
|
||||
// ==================== Crypto Utilities for Extensions ====================
|
||||
|
||||
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
|
||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -190,7 +171,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
plaintext := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
// Derive 32-byte key from provided key string
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||
@@ -207,7 +187,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
|
||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -227,14 +206,13 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Derive 32-byte key from provided key string
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -244,9 +222,8 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// cryptoGenerateKey generates a random encryption key
|
||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
length := 32 // Default 256-bit key
|
||||
length := 32
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||
length = int(l)
|
||||
@@ -268,7 +245,9 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Logging Functions ====================
|
||||
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)
|
||||
@@ -302,8 +281,6 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// ==================== Go Backend Wrappers ====================
|
||||
|
||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -312,7 +289,6 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(sanitizeFilename(input))
|
||||
}
|
||||
|
||||
// RegisterGoBackendAPIs adds more Go backend functions to the VM
|
||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.Get("gobackend")
|
||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||
@@ -322,7 +298,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
|
||||
obj := gobackendObj.(*goja.Object)
|
||||
|
||||
// Expose sanitizeFilename
|
||||
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
@@ -330,7 +305,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
// Expose getAudioQuality
|
||||
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
@@ -353,7 +327,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
})
|
||||
})
|
||||
|
||||
// Expose buildFilename
|
||||
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue("")
|
||||
@@ -369,4 +342,23 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
|
||||
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(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension settings storage
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -9,20 +8,17 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ExtensionSettingsStore manages settings for all extensions
|
||||
type ExtensionSettingsStore struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
settings map[string]map[string]interface{} // extensionID -> settings
|
||||
}
|
||||
|
||||
// Global settings store
|
||||
var (
|
||||
globalSettingsStore *ExtensionSettingsStore
|
||||
globalSettingsStoreOnce sync.Once
|
||||
)
|
||||
|
||||
// GetExtensionSettingsStore returns the global settings store
|
||||
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||
globalSettingsStoreOnce.Do(func() {
|
||||
globalSettingsStore = &ExtensionSettingsStore{
|
||||
@@ -32,7 +28,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||
return globalSettingsStore
|
||||
}
|
||||
|
||||
// SetDataDir sets the data directory for settings storage
|
||||
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -42,16 +37,13 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
// Load all existing settings
|
||||
return s.loadAllSettings()
|
||||
}
|
||||
|
||||
// getSettingsPath returns the path to an extension's settings file
|
||||
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
||||
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
||||
}
|
||||
|
||||
// loadAllSettings loads settings for all extensions from disk
|
||||
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||
entries, err := os.ReadDir(s.dataDir)
|
||||
if err != nil {
|
||||
@@ -76,7 +68,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSettings loads settings for a specific extension
|
||||
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
@@ -95,11 +86,9 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// saveSettings saves settings for a specific extension
|
||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(settingsPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -113,8 +102,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
|
||||
return os.WriteFile(settingsPath, data, 0644)
|
||||
}
|
||||
|
||||
// Get retrieves a setting value for an extension
|
||||
// Returns error if extension or key not found (gomobile compatible)
|
||||
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -131,7 +118,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// GetAll retrieves all settings for an extension
|
||||
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -141,7 +127,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Return a copy
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range extSettings {
|
||||
result[k] = v
|
||||
@@ -149,7 +134,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
|
||||
return result
|
||||
}
|
||||
|
||||
// Set stores a setting value for an extension
|
||||
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -160,22 +144,18 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
|
||||
|
||||
s.settings[extensionID][key] = value
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||
}
|
||||
|
||||
// SetAll stores all settings for an extension
|
||||
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.settings[extensionID] = settings
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, settings)
|
||||
}
|
||||
|
||||
// Remove removes a setting for an extension
|
||||
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -187,18 +167,15 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||
|
||||
delete(extSettings, key)
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, extSettings)
|
||||
}
|
||||
|
||||
// RemoveAll removes all settings for an extension
|
||||
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.settings, extensionID)
|
||||
|
||||
// Remove settings file
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
@@ -207,7 +184,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllExtensionSettings returns settings for all extensions as JSON
|
||||
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Extension categories
|
||||
const (
|
||||
CategoryMetadata = "metadata"
|
||||
CategoryDownload = "download"
|
||||
@@ -20,29 +20,26 @@ const (
|
||||
CategoryIntegration = "integration"
|
||||
)
|
||||
|
||||
// StoreExtension represents an extension in the store
|
||||
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"`
|
||||
// Alternative camelCase fields (for flexibility)
|
||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getDisplayName() string {
|
||||
if e.DisplayName != "" {
|
||||
return e.DisplayName
|
||||
@@ -53,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string {
|
||||
return e.Name
|
||||
}
|
||||
|
||||
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getDownloadURL() string {
|
||||
if e.DownloadURL != "" {
|
||||
return e.DownloadURL
|
||||
@@ -61,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string {
|
||||
return e.DownloadURLAlt
|
||||
}
|
||||
|
||||
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getIconURL() string {
|
||||
if e.IconURL != "" {
|
||||
return e.IconURL
|
||||
@@ -69,7 +64,6 @@ func (e *StoreExtension) getIconURL() string {
|
||||
return e.IconURLAlt
|
||||
}
|
||||
|
||||
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getMinAppVersion() string {
|
||||
if e.MinAppVersion != "" {
|
||||
return e.MinAppVersion
|
||||
@@ -77,14 +71,12 @@ func (e *StoreExtension) getMinAppVersion() string {
|
||||
return e.MinAppVersionAlt
|
||||
}
|
||||
|
||||
// StoreRegistry represents the extension registry
|
||||
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"`
|
||||
@@ -104,7 +96,6 @@ type StoreExtensionResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
// ToResponse converts StoreExtension to normalized response
|
||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
return StoreExtensionResponse{
|
||||
ID: e.ID,
|
||||
@@ -123,7 +114,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// ExtensionStore manages the extension store
|
||||
type ExtensionStore struct {
|
||||
registryURL string
|
||||
cacheDir string
|
||||
@@ -144,7 +134,6 @@ const (
|
||||
cacheFileName = "store_cache.json"
|
||||
)
|
||||
|
||||
// InitExtensionStore initializes the extension store
|
||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
@@ -155,20 +144,17 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
// Try to load from disk cache
|
||||
extensionStore.loadDiskCache()
|
||||
}
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
// GetExtensionStore returns the singleton store instance
|
||||
func GetExtensionStore() *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
// loadDiskCache loads cached registry from disk
|
||||
func (s *ExtensionStore) loadDiskCache() {
|
||||
if s.cacheDir == "" {
|
||||
return
|
||||
@@ -194,7 +180,6 @@ func (s *ExtensionStore) loadDiskCache() {
|
||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
}
|
||||
|
||||
// saveDiskCache saves registry to disk cache
|
||||
func (s *ExtensionStore) saveDiskCache() {
|
||||
if s.cacheDir == "" || s.cache == nil {
|
||||
return
|
||||
@@ -217,23 +202,24 @@ func (s *ExtensionStore) saveDiskCache() {
|
||||
os.WriteFile(cachePath, data, 0644)
|
||||
}
|
||||
|
||||
// FetchRegistry fetches the extension registry from GitHub
|
||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
// Return cached if valid and not forcing refresh
|
||||
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}
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
resp, err := client.Get(s.registryURL)
|
||||
if err != nil {
|
||||
// Return cached data if available on network error
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
@@ -268,7 +254,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
// GetExtensionsWithStatus returns extensions with installation status
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
@@ -300,7 +285,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DownloadExtension downloads an extension package to the specified path
|
||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
@@ -319,9 +303,13 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
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}
|
||||
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||
resp, err := client.Get(ext.getDownloadURL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
@@ -332,7 +320,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
@@ -349,7 +336,20 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCategories returns all available categories
|
||||
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,
|
||||
@@ -360,7 +360,6 @@ func (s *ExtensionStore) GetCategories() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// SearchExtensions searches extensions by query
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||
extensions, err := s.GetExtensionsWithStatus()
|
||||
if err != nil {
|
||||
@@ -406,7 +405,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ClearCache clears the in-memory and disk cache
|
||||
func (s *ExtensionStore) ClearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
@@ -422,7 +420,6 @@ func (s *ExtensionStore) ClearCache() {
|
||||
LogInfo("ExtensionStore", "Cache cleared")
|
||||
}
|
||||
|
||||
// Helper: case-insensitive contains
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
return containsStr(toLower(s), substr)
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test allowed domains
|
||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||
}
|
||||
@@ -121,7 +120,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||
}
|
||||
|
||||
// Test blocked domains
|
||||
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||
t.Error("Expected blocked.com to be denied")
|
||||
}
|
||||
@@ -139,7 +137,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: true, // Enable file permission for test
|
||||
File: true,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
@@ -147,7 +145,6 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test valid path within sandbox
|
||||
validPath, err := runtime.validatePath("test.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||
@@ -156,13 +153,11 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
t.Error("Expected non-empty path")
|
||||
}
|
||||
|
||||
// Test path traversal attack
|
||||
_, err = runtime.validatePath("../../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("Expected path traversal to be blocked")
|
||||
}
|
||||
|
||||
// Test nested path within sandbox (should be allowed)
|
||||
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||
@@ -171,26 +166,23 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
t.Error("Expected non-empty nested path")
|
||||
}
|
||||
|
||||
// Test absolute path should be blocked (security fix)
|
||||
// Use platform-appropriate absolute path
|
||||
var absPath string
|
||||
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||
absPath = "C:\\Windows\\System32\\test.txt" // Windows
|
||||
absPath = "C:\\Windows\\System32\\test.txt"
|
||||
} else {
|
||||
absPath = "/etc/passwd" // Unix
|
||||
absPath = "/etc/passwd"
|
||||
}
|
||||
_, err = runtime.validatePath(absPath)
|
||||
if err == nil {
|
||||
t.Error("Expected absolute path to be blocked")
|
||||
}
|
||||
|
||||
// Test that extension without file permission is blocked
|
||||
extNoFile := &LoadedExtension{
|
||||
ID: "test-ext-no-file",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext-no-file",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: false, // No file permission
|
||||
File: false,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
@@ -215,7 +207,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
|
||||
// Test base64 encode/decode
|
||||
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("base64Encode failed: %v", err)
|
||||
@@ -232,7 +223,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||
}
|
||||
|
||||
// Test MD5
|
||||
result, err = vm.RunString(`utils.md5("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("md5 failed: %v", err)
|
||||
@@ -241,7 +231,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||
}
|
||||
|
||||
// Test JSON parse/stringify
|
||||
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||
if err != nil {
|
||||
t.Fatalf("stringifyJSON failed: %v", err)
|
||||
@@ -267,7 +256,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
// Test that private IPs are blocked (SSRF protection)
|
||||
privateIPs := []string{
|
||||
"http://localhost/admin",
|
||||
"http://127.0.0.1/admin",
|
||||
@@ -285,7 +273,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test that allowed public domain still works
|
||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||
}
|
||||
@@ -296,7 +283,6 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
host string
|
||||
expected bool
|
||||
}{
|
||||
// Private IPs should be blocked
|
||||
{"localhost", true},
|
||||
{"127.0.0.1", true},
|
||||
{"127.0.0.2", true},
|
||||
@@ -306,18 +292,17 @@ func TestIsPrivateIP(t *testing.T) {
|
||||
{"172.31.255.255", true},
|
||||
{"192.168.0.1", true},
|
||||
{"192.168.255.255", true},
|
||||
{"169.254.169.254", true}, // AWS metadata
|
||||
{"169.254.169.254", true},
|
||||
{"router.local", true},
|
||||
{"mydevice.local", true},
|
||||
|
||||
// Public IPs should be allowed
|
||||
{"8.8.8.8", false},
|
||||
{"1.1.1.1", false},
|
||||
{"api.example.com", false},
|
||||
{"google.com", false},
|
||||
{"172.15.0.1", false}, // Just outside 172.16-31 range
|
||||
{"172.32.0.1", false}, // Just outside 172.16-31 range
|
||||
{"192.167.0.1", false}, // Not 192.168.x.x
|
||||
{"172.15.0.1", false},
|
||||
{"172.32.0.1", false},
|
||||
{"192.167.0.1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
// Package gobackend provides timeout execution for extension JS code
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// JSExecutionError represents an error during JS execution
|
||||
type JSExecutionError struct {
|
||||
Message string
|
||||
IsTimeout bool
|
||||
@@ -20,8 +19,6 @@ func (e *JSExecutionError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// RunWithTimeout executes JavaScript code with a timeout
|
||||
// Returns the result value and any error (including timeout)
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultJSTimeout
|
||||
@@ -30,22 +27,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Channel to receive result
|
||||
type result struct {
|
||||
value goja.Value
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
|
||||
// Track if we've interrupted
|
||||
var interrupted bool
|
||||
var interruptMu sync.Mutex
|
||||
|
||||
// Run script in goroutine
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Check if this was our interrupt
|
||||
interruptMu.Lock()
|
||||
wasInterrupted := interrupted
|
||||
interruptMu.Unlock()
|
||||
@@ -56,6 +49,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
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)}
|
||||
}
|
||||
}
|
||||
@@ -65,22 +59,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
resultCh <- result{val, err}
|
||||
}()
|
||||
|
||||
// Wait for result or timeout
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.value, res.err
|
||||
case <-ctx.Done():
|
||||
// Timeout - interrupt the VM
|
||||
interruptMu.Lock()
|
||||
interrupted = true
|
||||
interruptMu.Unlock()
|
||||
|
||||
vm.Interrupt("execution timeout")
|
||||
|
||||
// Wait a bit for the goroutine to finish
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
// If we got a result after interrupt, it might be the timeout error
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
@@ -89,7 +79,6 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
IsTimeout: true,
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
// Force return timeout error
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
@@ -109,7 +98,6 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
|
||||
return result, err
|
||||
}
|
||||
|
||||
// IsTimeoutError checks if an error is a timeout error
|
||||
func IsTimeoutError(err error) bool {
|
||||
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||
return jsErr.IsTimeout
|
||||
|
||||
@@ -3,77 +3,156 @@ package gobackend
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Invalid filename characters for Android/Windows/Linux
|
||||
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
var (
|
||||
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
multiUnderscore = regexp.MustCompile(`_+`)
|
||||
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
|
||||
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||
)
|
||||
|
||||
// sanitizeFilename removes invalid characters from filename
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
|
||||
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
sanitized = strings.Trim(sanitized, ".")
|
||||
|
||||
multiUnderscore := regexp.MustCompile(`_+`)
|
||||
|
||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
}
|
||||
|
||||
|
||||
if sanitized == "" {
|
||||
sanitized = "untitled"
|
||||
}
|
||||
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// buildFilenameFromTemplate builds a filename from template and metadata
|
||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
}
|
||||
|
||||
result := template
|
||||
|
||||
placeholders := map[string]string{
|
||||
"{title}": getString(metadata, "title"),
|
||||
"{artist}": getString(metadata, "artist"),
|
||||
"{album}": getString(metadata, "album"),
|
||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||
"{year}": getString(metadata, "year"),
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
|
||||
result := replaceFormattedNumberPlaceholders(template, metadata)
|
||||
result = replaceDateFormatPlaceholders(result, metadata)
|
||||
|
||||
dateValue := getDateValue(metadata)
|
||||
yearValue := getString(metadata, "year")
|
||||
if yearValue == "" {
|
||||
yearValue = extractYear(dateValue)
|
||||
}
|
||||
|
||||
|
||||
placeholders := map[string]string{
|
||||
"{title}": getString(metadata, "title"),
|
||||
"{artist}": getString(metadata, "artist"),
|
||||
"{album}": getString(metadata, "album"),
|
||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||
"{year}": yearValue,
|
||||
"{date}": dateValue,
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
|
||||
}
|
||||
|
||||
for placeholder, value := range placeholders {
|
||||
result = strings.ReplaceAll(result, placeholder, value)
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func replaceFormattedNumberPlaceholders(template string, metadata map[string]interface{}) string {
|
||||
return formattedNumberPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||
parts := formattedNumberPlaceholderExpr.FindStringSubmatch(match)
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
number := getInt(metadata, parts[1])
|
||||
width, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatNumberWithWidth(number, width)
|
||||
})
|
||||
}
|
||||
|
||||
func replaceDateFormatPlaceholders(template string, metadata map[string]interface{}) string {
|
||||
return dateFormatPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||
parts := dateFormatPlaceholderExpr.FindStringSubmatch(match)
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return formatDateWithPattern(getDateValue(metadata), parts[1])
|
||||
})
|
||||
}
|
||||
|
||||
func getDateValue(metadata map[string]interface{}) string {
|
||||
date := getString(metadata, "date")
|
||||
if date != "" {
|
||||
return date
|
||||
}
|
||||
|
||||
releaseDate := getString(metadata, "release_date")
|
||||
if releaseDate != "" {
|
||||
return releaseDate
|
||||
}
|
||||
|
||||
return getString(metadata, "year")
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s)
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value)
|
||||
case int:
|
||||
return strconv.Itoa(value)
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10)
|
||||
case float64:
|
||||
return strconv.Itoa(int(value))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt(m map[string]interface{}, key string) int {
|
||||
if v, ok := m[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
candidateKeys := []string{key}
|
||||
switch key {
|
||||
case "track":
|
||||
candidateKeys = append(candidateKeys, "track_number")
|
||||
case "disc":
|
||||
candidateKeys = append(candidateKeys, "disc_number")
|
||||
}
|
||||
|
||||
for _, candidate := range candidateKeys {
|
||||
if v, ok := m[candidate]; ok {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(n))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -91,7 +170,129 @@ func formatDiscNumber(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
|
||||
func formatRawNumber(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
func formatNumberWithWidth(n int, width int) string {
|
||||
if n <= 0 || width <= 0 {
|
||||
return ""
|
||||
}
|
||||
if width <= 1 {
|
||||
return formatRawNumber(n)
|
||||
}
|
||||
return fmt.Sprintf("%0*d", width, n)
|
||||
}
|
||||
|
||||
func formatDateWithPattern(rawDate string, strftimePattern string) string {
|
||||
if rawDate == "" || strftimePattern == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsedDate, ok := parseMetadataDate(rawDate)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
goLayout := convertStrftimeToGoLayout(strftimePattern)
|
||||
if goLayout == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parsedDate.Format(goLayout)
|
||||
}
|
||||
|
||||
func parseMetadataDate(rawDate string) (time.Time, bool) {
|
||||
clean := strings.TrimSpace(rawDate)
|
||||
if clean == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02",
|
||||
"2006-01",
|
||||
"2006",
|
||||
"2006/01/02",
|
||||
"2006/01",
|
||||
"2006.01.02",
|
||||
"2006.01",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, clean)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
|
||||
if len(clean) >= 10 {
|
||||
parsed, err := time.Parse("2006-01-02", clean[:10])
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
|
||||
yearMatch := yearPattern.FindString(clean)
|
||||
if yearMatch == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
year, err := strconv.Atoi(yearMatch)
|
||||
if err != nil || year <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
return time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), true
|
||||
}
|
||||
|
||||
func convertStrftimeToGoLayout(pattern string) string {
|
||||
if pattern == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for i := 0; i < len(pattern); i++ {
|
||||
ch := pattern[i]
|
||||
if ch != '%' {
|
||||
builder.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
if i+1 >= len(pattern) {
|
||||
builder.WriteByte('%')
|
||||
break
|
||||
}
|
||||
|
||||
i++
|
||||
switch pattern[i] {
|
||||
case 'Y':
|
||||
builder.WriteString("2006")
|
||||
case 'y':
|
||||
builder.WriteString("06")
|
||||
case 'm':
|
||||
builder.WriteString("01")
|
||||
case 'd':
|
||||
builder.WriteString("02")
|
||||
case 'b':
|
||||
builder.WriteString("Jan")
|
||||
case 'B':
|
||||
builder.WriteString("January")
|
||||
case '%':
|
||||
builder.WriteByte('%')
|
||||
default:
|
||||
builder.WriteByte('%')
|
||||
builder.WriteByte(pattern[i])
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func extractYear(date string) string {
|
||||
if len(date) >= 4 {
|
||||
return date[:4]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"title": "Song Name",
|
||||
"artist": "Artist Name",
|
||||
"album": "Album Name",
|
||||
"track": 1,
|
||||
"disc": 2,
|
||||
"year": "2025",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{artist} - {track} - {track_raw} - d{disc} - d{disc_raw} - {title}",
|
||||
metadata,
|
||||
)
|
||||
|
||||
expected := "Artist Name - 01 - 1 - d2 - d2 - Song Name"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"title": "Song Name",
|
||||
"artist": "Artist Name",
|
||||
"track": 0,
|
||||
"disc": 0,
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate("{track_raw}-{disc_raw}-{title}", metadata)
|
||||
expected := "--Song Name"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"track": 3,
|
||||
"disc": 2,
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate("{track:1}-{track:02}-{disc:03}", metadata)
|
||||
expected := "3-03-002"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"release_date": "2024-03-09",
|
||||
"track_number": 7,
|
||||
"disc_number": 1,
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{artist} - {track:02} - {title} - {date:%Y-%m-%d} - {year}",
|
||||
metadata,
|
||||
)
|
||||
expected := "Artist Name - 07 - Song Name - 2024-03-09 - 2024"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"date": "2019",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate("{date:%Y}-{date:%m}-{date:%d}", metadata)
|
||||
expected := "2019-01-01"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.24.5
|
||||
toolchain go1.25.7
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
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-20260216154549-8b74ce4618c5
|
||||
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-20260211191516-dcd2a3258864
|
||||
golang.org/x/net v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||
golang.org/x/mod v0.31.0 // 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/text v0.3.8 // 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,28 +1,68 @@
|
||||
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 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=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/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=
|
||||
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/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/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||
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/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
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=
|
||||
|
||||
@@ -11,74 +11,43 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTP utility functions for consistent request handling across all downloaders
|
||||
|
||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
||||
func getRandomUserAgent() string {
|
||||
winMajor := rand.Intn(2) + 10
|
||||
|
||||
chromeVersion := rand.Intn(25) + 100
|
||||
chromeBuild := rand.Intn(1500) + 3000
|
||||
chromePatch := rand.Intn(65) + 60
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
chromeBuild := rand.Intn(1500) + 6000
|
||||
chromePatch := rand.Intn(200) + 100
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||
winMajor,
|
||||
"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,
|
||||
)
|
||||
}
|
||||
|
||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||
// func getRandomMacUserAgent() string {
|
||||
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||
// webkitMajor := rand.Intn(7) + 530
|
||||
// webkitMinor := rand.Intn(7) + 30
|
||||
// chromeMajor := rand.Intn(25) + 80
|
||||
// chromeBuild := rand.Intn(1500) + 3000
|
||||
// chromePatch := rand.Intn(65) + 60
|
||||
// safariMajor := rand.Intn(7) + 530
|
||||
// safariMinor := rand.Intn(6) + 30
|
||||
//
|
||||
// return fmt.Sprintf(
|
||||
// "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,
|
||||
// )
|
||||
// }
|
||||
|
||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||
// func getRandomDesktopUserAgent() string {
|
||||
// if rand.Intn(2) == 0 {
|
||||
// return getRandomUserAgent() // Windows
|
||||
// }
|
||||
// return getRandomMacUserAgent() // Mac
|
||||
// }
|
||||
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second
|
||||
DownloadTimeout = 120 * time.Second
|
||||
SongLinkTimeout = 30 * time.Second
|
||||
DefaultMaxRetries = 3
|
||||
DefaultRetryDelay = 1 * time.Second
|
||||
Second = time.Second
|
||||
)
|
||||
|
||||
type NetworkCompatibilityOptions struct {
|
||||
AllowHTTP bool
|
||||
InsecureTLS bool
|
||||
}
|
||||
|
||||
var (
|
||||
networkCompatibilityMu sync.RWMutex
|
||||
networkCompatibilityOptions NetworkCompatibilityOptions
|
||||
)
|
||||
|
||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||
var sharedTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -97,20 +66,49 @@ var sharedTransport = &http.Transport{
|
||||
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,
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
var downloadClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
Timeout: DownloadTimeout,
|
||||
}
|
||||
|
||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// 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: newCompatibilityTransport(metadataTransport),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
@@ -123,12 +121,114 @@ func GetDownloadClient() *http.Client {
|
||||
return downloadClient
|
||||
}
|
||||
|
||||
// CloseIdleConnections closes idle connections in the shared transport
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
metadataTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
|
||||
networkCompatibilityMu.Lock()
|
||||
networkCompatibilityOptions = NetworkCompatibilityOptions{
|
||||
AllowHTTP: allowHTTP,
|
||||
InsecureTLS: insecureTLS,
|
||||
}
|
||||
networkCompatibilityMu.Unlock()
|
||||
|
||||
applyTLSCompatibility(sharedTransport, insecureTLS)
|
||||
applyTLSCompatibility(metadataTransport, insecureTLS)
|
||||
CloseIdleConnections()
|
||||
|
||||
GoLog("[HTTP] Network compatibility options updated: allow_http=%v insecure_tls=%v\n", allowHTTP, insecureTLS)
|
||||
}
|
||||
|
||||
func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
|
||||
networkCompatibilityMu.RLock()
|
||||
defer networkCompatibilityMu.RUnlock()
|
||||
return networkCompatibilityOptions
|
||||
}
|
||||
|
||||
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
|
||||
if insecureTLS {
|
||||
cfg := &tls.Config{InsecureSkipVerify: true}
|
||||
if transport.TLSClientConfig != nil {
|
||||
cfg = transport.TLSClientConfig.Clone()
|
||||
cfg.InsecureSkipVerify = true
|
||||
}
|
||||
transport.TLSClientConfig = cfg
|
||||
return
|
||||
}
|
||||
|
||||
transport.TLSClientConfig = nil
|
||||
}
|
||||
|
||||
type compatibilityTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper {
|
||||
return &compatibilityTransport{base: base}
|
||||
}
|
||||
|
||||
func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
if !opts.AllowHTTP || req.URL.Scheme != "https" {
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// Compatibility mode should prefer HTTPS and only fallback to HTTP on
|
||||
// transport-level failures. Forcing HTTP unconditionally can trigger
|
||||
// redirect loops (http -> https) on providers that enforce HTTPS.
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if !canFallbackToHTTP(req) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fallbackReq, cloneErr := cloneRequestWithHTTPScheme(req, "http")
|
||||
if cloneErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
GoLog("[HTTP] HTTPS request failed for %s, retrying over HTTP: %v\n", req.URL.Host, err)
|
||||
return t.base.RoundTrip(fallbackReq)
|
||||
}
|
||||
|
||||
func canFallbackToHTTP(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToUpper(req.Method) {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodDelete:
|
||||
return true
|
||||
default:
|
||||
return req.GetBody != nil
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request, error) {
|
||||
reqCopy := req.Clone(req.Context())
|
||||
if req.Body != nil && req.GetBody != nil {
|
||||
bodyCopy, err := req.GetBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqCopy.Body = bodyCopy
|
||||
}
|
||||
|
||||
urlCopy := *req.URL
|
||||
urlCopy.Scheme = scheme
|
||||
reqCopy.URL = &urlCopy
|
||||
return reqCopy, nil
|
||||
}
|
||||
|
||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||
// Also checks for ISP blocking on errors
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
@@ -147,7 +247,6 @@ type RetryConfig struct {
|
||||
BackoffFactor float64
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns default retry configuration
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: DefaultMaxRetries,
|
||||
@@ -157,16 +256,11 @@ func DefaultRetryConfig() RetryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
||||
// Also detects and logs ISP blocking
|
||||
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())
|
||||
|
||||
@@ -174,10 +268,8 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
// Check for ISP blocking on network errors
|
||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||
// Don't retry if ISP blocking is detected - it won't help
|
||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
|
||||
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
|
||||
}
|
||||
|
||||
if attempt < config.MaxRetries {
|
||||
@@ -189,12 +281,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
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)
|
||||
@@ -234,7 +324,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
}
|
||||
}
|
||||
|
||||
// Server errors (5xx) - retry
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||
@@ -246,20 +335,17 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
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)
|
||||
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")
|
||||
@@ -267,12 +353,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 {
|
||||
@@ -283,8 +367,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")
|
||||
@@ -302,7 +384,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")
|
||||
@@ -315,14 +396,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] + "..."
|
||||
}
|
||||
@@ -331,7 +410,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
||||
return msg
|
||||
}
|
||||
|
||||
// ISPBlockingError represents an error caused by ISP blocking
|
||||
type ISPBlockingError struct {
|
||||
Domain string
|
||||
Reason string
|
||||
@@ -342,18 +420,14 @@ func (e *ISPBlockingError) Error() string {
|
||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||
}
|
||||
|
||||
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
||||
// Returns the ISPBlockingError if detected, nil otherwise
|
||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract domain from URL
|
||||
domain := extractDomain(requestURL)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Check for DNS resolution failure (common ISP blocking method)
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||
@@ -365,11 +439,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for connection refused (ISP firewall blocking)
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if opErr.Op == "dial" {
|
||||
// Check for specific syscall errors
|
||||
var syscallErr syscall.Errno
|
||||
if errors.As(opErr.Err, &syscallErr) {
|
||||
switch syscallErr {
|
||||
@@ -408,7 +480,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
||||
var tlsErr *tls.RecordHeaderError
|
||||
if errors.As(err, &tlsErr) {
|
||||
return &ISPBlockingError{
|
||||
@@ -418,7 +489,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
}
|
||||
}
|
||||
|
||||
// Check error message patterns for common ISP blocking indicators
|
||||
blockingPatterns := []struct {
|
||||
pattern string
|
||||
reason string
|
||||
@@ -447,7 +517,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
|
||||
// Returns true if ISP blocking was detected
|
||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
ispErr := IsISPBlocking(err, requestURL)
|
||||
@@ -462,7 +531,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// extractDomain extracts the domain from a URL string
|
||||
func extractDomain(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return "unknown"
|
||||
@@ -470,7 +538,6 @@ func extractDomain(rawURL string) string {
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
// Try to extract domain manually
|
||||
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||
@@ -485,7 +552,6 @@ func extractDomain(rawURL string) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
|
||||
// If ISP blocking is detected, returns a more descriptive error
|
||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||
if err == nil {
|
||||
|
||||
@@ -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,175 @@
|
||||
//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"
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
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...")
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
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)
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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)
|
||||
)
|
||||
|
||||
type IDHSSearchRequest struct {
|
||||
Link string `json:"link"`
|
||||
Adapters []string `json:"adapters,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
|
||||
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,711 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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,
|
||||
".cue": true,
|
||||
}
|
||||
|
||||
type libraryAudioFileInfo struct {
|
||||
path string
|
||||
modTime int64
|
||||
}
|
||||
|
||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||
var files []libraryAudioFileInfo
|
||||
|
||||
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() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if !supportedAudioFormats[ext] {
|
||||
return nil
|
||||
}
|
||||
|
||||
files = append(files, libraryAudioFileInfo{
|
||||
path: path,
|
||||
modTime: info.ModTime().UnixMilli(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
audioFileInfos, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
audioFiles := make([]string, 0, len(audioFileInfos))
|
||||
for _, fileInfo := range audioFileInfos {
|
||||
audioFiles = append(audioFiles, fileInfo.path)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Track audio files referenced by .cue sheets to avoid duplicates
|
||||
cueReferencedAudioFiles := make(map[string]bool)
|
||||
|
||||
// First pass: scan .cue files to collect referenced audio paths
|
||||
for _, filePath := range audioFiles {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if ext == ".cue" {
|
||||
sheet, err := ParseCueFile(filePath)
|
||||
if err == nil && sheet.FileName != "" {
|
||||
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
|
||||
if audioPath != "" {
|
||||
cueReferencedAudioFiles[audioPath] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
// Handle .cue files: produce multiple track results
|
||||
if ext == ".cue" {
|
||||
cueResults, err := ScanCueFileForLibrary(filePath, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||
continue
|
||||
}
|
||||
results = append(results, cueResults...)
|
||||
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip audio files that are referenced by a .cue sheet
|
||||
// (they will be represented by the cue sheet's track entries instead)
|
||||
if cueReferencedAudioFiles[filePath] {
|
||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||
continue
|
||||
}
|
||||
|
||||
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, "."),
|
||||
}
|
||||
|
||||
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 applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
}
|
||||
libraryScanCancel = make(chan struct{})
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||
if err != nil {
|
||||
return "{}", err
|
||||
}
|
||||
currentPathSet := make(map[string]bool, len(currentFiles))
|
||||
for _, fileInfo := range currentFiles {
|
||||
currentPathSet[fileInfo.path] = true
|
||||
}
|
||||
|
||||
totalFiles := len(currentFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Find files to scan (new or modified)
|
||||
var filesToScan []libraryAudioFileInfo
|
||||
skippedCount := 0
|
||||
|
||||
// Build a set of existing CUE virtual path base files for incremental matching.
|
||||
// CUE tracks are stored with virtual paths like "/path/album.cue#track01".
|
||||
// We need to match these against the actual .cue file's modTime.
|
||||
cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk
|
||||
for _, f := range currentFiles {
|
||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||
cueBaseModTimes[f.path] = f.modTime
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range currentFiles {
|
||||
existingModTime, exists := existingFiles[f.path]
|
||||
if !exists {
|
||||
// For .cue files, also check if any virtual path entries exist
|
||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||
hasCueTracks := false
|
||||
for existingPath := range existingFiles {
|
||||
if strings.HasPrefix(existingPath, f.path+"#track") {
|
||||
hasCueTracks = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasCueTracks {
|
||||
// CUE file exists in DB via virtual paths; check if modTime changed
|
||||
// Use modTime from any virtual path (they all share the same .cue modTime)
|
||||
for existingPath, modTime := range existingFiles {
|
||||
if strings.HasPrefix(existingPath, f.path+"#track") {
|
||||
if f.modTime == modTime {
|
||||
skippedCount++
|
||||
} else {
|
||||
filesToScan = append(filesToScan, f)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else if f.modTime != existingModTime {
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else {
|
||||
skippedCount++
|
||||
}
|
||||
}
|
||||
|
||||
var deletedPaths []string
|
||||
for existingPath := range existingFiles {
|
||||
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
|
||||
// check if the base .cue file still exists on disk
|
||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||
baseCuePath := existingPath[:idx]
|
||||
if currentPathSet[baseCuePath] {
|
||||
continue // Base .cue file still exists, not deleted
|
||||
}
|
||||
// Base CUE file is gone, mark virtual path as deleted
|
||||
deletedPaths = append(deletedPaths, existingPath)
|
||||
} else 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
|
||||
}
|
||||
|
||||
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
|
||||
cueReferencedAudioFilesInc := make(map[string]bool)
|
||||
for _, f := range filesToScan {
|
||||
ext := strings.ToLower(filepath.Ext(f.path))
|
||||
if ext == ".cue" {
|
||||
sheet, err := ParseCueFile(f.path)
|
||||
if err == nil && sheet.FileName != "" {
|
||||
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
||||
if audioPath != "" {
|
||||
cueReferencedAudioFilesInc[audioPath] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(f.path))
|
||||
|
||||
// Handle .cue files: produce multiple track results
|
||||
if ext == ".cue" {
|
||||
cueResults, err := ScanCueFileForLibrary(f.path, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||
continue
|
||||
}
|
||||
results = append(results, cueResults...)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip audio files referenced by .cue sheets
|
||||
if cueReferencedAudioFilesInc[f.path] {
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -3,12 +3,12 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry represents a single log entry
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
@@ -16,7 +16,6 @@ type LogEntry struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
|
||||
type LogBuffer struct {
|
||||
entries []LogEntry
|
||||
maxSize int
|
||||
@@ -24,38 +23,61 @@ type LogBuffer struct {
|
||||
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._~+/\-]+=*`)
|
||||
)
|
||||
|
||||
// GetLogBuffer returns the singleton log buffer instance
|
||||
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, 1000),
|
||||
maxSize: 1000,
|
||||
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||
maxSize: defaultLogBufferSize,
|
||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||
}
|
||||
})
|
||||
return globalLogBuffer
|
||||
}
|
||||
|
||||
// SetLoggingEnabled enables or disables logging
|
||||
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
|
||||
}
|
||||
|
||||
// IsLoggingEnabled returns whether logging is enabled
|
||||
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return lb.loggingEnabled
|
||||
}
|
||||
|
||||
// Add adds a log entry to the buffer
|
||||
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
@@ -64,6 +86,9 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
return
|
||||
}
|
||||
|
||||
message = sanitizeSensitiveLogText(message)
|
||||
message = truncateLogMessage(message)
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format("15:04:05.000"),
|
||||
Level: level,
|
||||
@@ -79,7 +104,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
fmt.Printf("[%s] %s\n", tag, message)
|
||||
}
|
||||
|
||||
// GetAll returns all log entries as JSON
|
||||
func (lb *LogBuffer) GetAll() string {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
@@ -103,21 +127,18 @@ func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||
return entries, len(lb.entries)
|
||||
}
|
||||
|
||||
// Clear clears all log entries
|
||||
func (lb *LogBuffer) Clear() {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.entries = lb.entries[:0]
|
||||
}
|
||||
|
||||
// Count returns the number of log entries
|
||||
func (lb *LogBuffer) Count() int {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return len(lb.entries)
|
||||
}
|
||||
|
||||
// Helper functions for logging with different levels
|
||||
func LogDebug(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -154,11 +175,11 @@ func GoLog(format string, args ...interface{}) {
|
||||
|
||||
// Determine level from message content
|
||||
msgLower := strings.ToLower(message)
|
||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(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.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||
} 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"
|
||||
@@ -167,15 +188,10 @@ func GoLog(format string, args ...interface{}) {
|
||||
GetLogBuffer().Add(level, tag, message)
|
||||
}
|
||||
|
||||
// Exported functions for Flutter
|
||||
|
||||
// GetLogs returns all logs as JSON array
|
||||
func GetLogs() string {
|
||||
return GetLogBuffer().GetAll()
|
||||
}
|
||||
|
||||
// GetLogsSince returns logs since the given index
|
||||
// Returns JSON: {"logs": [...], "next_index": N}
|
||||
func GetLogsSince(index int) string {
|
||||
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||
logsJson, _ := json.Marshal(entries)
|
||||
@@ -183,17 +199,14 @@ func GetLogsSince(index int) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearLogs clears all logs
|
||||
func ClearLogs() {
|
||||
GetLogBuffer().Clear()
|
||||
}
|
||||
|
||||
// GetLogCount returns the number of log entries
|
||||
func GetLogCount() int {
|
||||
return GetLogBuffer().Count()
|
||||
}
|
||||
|
||||
// SetLoggingEnabled enables or disables logging from Flutter
|
||||
func SetLoggingEnabled(enabled bool) {
|
||||
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -13,15 +15,153 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Lyrics Cache with TTL
|
||||
// ========================================
|
||||
|
||||
const (
|
||||
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours
|
||||
durationToleranceSec = 10.0 // Duration matching tolerance in seconds
|
||||
lyricsCacheTTL = 24 * time.Hour
|
||||
durationToleranceSec = 10.0
|
||||
)
|
||||
|
||||
// Lyrics provider names (used in settings and cascade ordering)
|
||||
const (
|
||||
LyricsProviderSpotifyAPI = "spotify_api"
|
||||
LyricsProviderLRCLIB = "lrclib"
|
||||
LyricsProviderNetease = "netease"
|
||||
LyricsProviderMusixmatch = "musixmatch"
|
||||
LyricsProviderAppleMusic = "apple_music"
|
||||
LyricsProviderQQMusic = "qqmusic"
|
||||
)
|
||||
|
||||
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
|
||||
// LRCLIB first (no proxy dependency), then the others.
|
||||
var DefaultLyricsProviders = []string{
|
||||
LyricsProviderLRCLIB,
|
||||
LyricsProviderSpotifyAPI,
|
||||
LyricsProviderMusixmatch,
|
||||
LyricsProviderNetease,
|
||||
LyricsProviderAppleMusic,
|
||||
LyricsProviderQQMusic,
|
||||
}
|
||||
|
||||
var (
|
||||
lyricsProvidersMu sync.RWMutex
|
||||
lyricsProviders []string // ordered list of enabled providers
|
||||
)
|
||||
|
||||
var (
|
||||
spotifyLyricsRateLimitMu sync.RWMutex
|
||||
spotifyLyricsRateLimitedTil time.Time
|
||||
)
|
||||
|
||||
// LyricsFetchOptions controls optional provider-specific enhancements.
|
||||
type LyricsFetchOptions struct {
|
||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
|
||||
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
|
||||
}
|
||||
|
||||
var defaultLyricsFetchOptions = LyricsFetchOptions{
|
||||
IncludeTranslationNetease: false,
|
||||
IncludeRomanizationNetease: false,
|
||||
MultiPersonWordByWord: true,
|
||||
MusixmatchLanguage: "",
|
||||
}
|
||||
|
||||
var (
|
||||
lyricsFetchOptionsMu sync.RWMutex
|
||||
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||
)
|
||||
|
||||
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
|
||||
// Providers not in the list are disabled. An empty list resets to defaults.
|
||||
func SetLyricsProviderOrder(providers []string) {
|
||||
lyricsProvidersMu.Lock()
|
||||
defer lyricsProvidersMu.Unlock()
|
||||
|
||||
if len(providers) == 0 {
|
||||
lyricsProviders = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Validate provider names
|
||||
validNames := map[string]bool{
|
||||
LyricsProviderSpotifyAPI: true,
|
||||
LyricsProviderLRCLIB: true,
|
||||
LyricsProviderNetease: true,
|
||||
LyricsProviderMusixmatch: true,
|
||||
LyricsProviderAppleMusic: true,
|
||||
LyricsProviderQQMusic: true,
|
||||
}
|
||||
|
||||
var valid []string
|
||||
for _, p := range providers {
|
||||
normalized := strings.ToLower(strings.TrimSpace(p))
|
||||
if validNames[normalized] {
|
||||
valid = append(valid, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
lyricsProviders = valid
|
||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||
}
|
||||
|
||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
||||
func GetLyricsProviderOrder() []string {
|
||||
lyricsProvidersMu.RLock()
|
||||
defer lyricsProvidersMu.RUnlock()
|
||||
|
||||
if len(lyricsProviders) == 0 {
|
||||
return DefaultLyricsProviders
|
||||
}
|
||||
|
||||
result := make([]string, len(lyricsProviders))
|
||||
copy(result, lyricsProviders)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
||||
opts.MusixmatchLanguage = strings.ToLower(strings.TrimSpace(opts.MusixmatchLanguage))
|
||||
opts.MusixmatchLanguage = regexp.MustCompile(`[^a-z0-9\-_]`).ReplaceAllString(opts.MusixmatchLanguage, "")
|
||||
if len(opts.MusixmatchLanguage) > 16 {
|
||||
opts.MusixmatchLanguage = opts.MusixmatchLanguage[:16]
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
normalized := normalizeLyricsFetchOptions(opts)
|
||||
|
||||
lyricsFetchOptionsMu.Lock()
|
||||
defer lyricsFetchOptionsMu.Unlock()
|
||||
lyricsFetchOptions = normalized
|
||||
|
||||
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
|
||||
normalized.IncludeTranslationNetease,
|
||||
normalized.IncludeRomanizationNetease,
|
||||
normalized.MultiPersonWordByWord,
|
||||
normalized.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
|
||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||
lyricsFetchOptionsMu.RLock()
|
||||
defer lyricsFetchOptionsMu.RUnlock()
|
||||
return lyricsFetchOptions
|
||||
}
|
||||
|
||||
type lyricsCacheEntry struct {
|
||||
response *LyricsResponse
|
||||
expiresAt time.Time
|
||||
@@ -37,10 +177,8 @@ var globalLyricsCache = &lyricsCache{
|
||||
}
|
||||
|
||||
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
|
||||
// Normalize key: lowercase, trim spaces
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
|
||||
// Round duration to nearest 10 seconds for cache key
|
||||
roundedDuration := math.Round(durationSec/10) * 10
|
||||
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
|
||||
}
|
||||
@@ -55,7 +193,6 @@ func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsRes
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
@@ -74,7 +211,6 @@ func (c *lyricsCache) Set(artist, track string, durationSec float64, response *L
|
||||
}
|
||||
}
|
||||
|
||||
// CleanExpired removes expired entries from cache
|
||||
func (c *lyricsCache) CleanExpired() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -90,13 +226,21 @@ func (c *lyricsCache) CleanExpired() int {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Size returns current cache size
|
||||
func (c *lyricsCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
func (c *lyricsCache) ClearAll() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cleared := len(c.cache)
|
||||
c.cache = make(map[string]*lyricsCacheEntry)
|
||||
return cleared
|
||||
}
|
||||
|
||||
type LRCLibResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -109,6 +253,18 @@ type LRCLibResponse struct {
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
type SpotifyLyricsLine struct {
|
||||
TimeTag string `json:"timeTag"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
type SpotifyLyricsAPIResponse struct {
|
||||
Error bool `json:"error"`
|
||||
Message string `json:"message"`
|
||||
SyncType string `json:"syncType"`
|
||||
Lines []SpotifyLyricsLine `json:"lines"`
|
||||
}
|
||||
|
||||
type LyricsLine struct {
|
||||
StartTimeMs int64 `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
@@ -130,9 +286,7 @@ type LyricsClient struct {
|
||||
|
||||
func NewLyricsClient() *LyricsClient {
|
||||
return &LyricsClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +302,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -172,8 +326,6 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
||||
return c.parseLRCLibResponse(&lrcResp), nil
|
||||
}
|
||||
|
||||
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
|
||||
// durationSec: track duration in seconds, use 0 to skip duration matching
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
|
||||
baseURL := "https://lrclib.net/api/search"
|
||||
params := url.Values{}
|
||||
@@ -185,7 +337,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Android/1.0")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -206,13 +358,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
return nil, fmt.Errorf("no lyrics found")
|
||||
}
|
||||
|
||||
// Filter and score results based on duration matching and synced lyrics
|
||||
bestMatch := c.findBestMatch(results, durationSec)
|
||||
if bestMatch != nil {
|
||||
return c.parseLRCLibResponse(bestMatch), nil
|
||||
}
|
||||
|
||||
// Fallback: return first result with synced lyrics
|
||||
for _, result := range results {
|
||||
if result.SyncedLyrics != "" {
|
||||
return c.parseLRCLibResponse(&result), nil
|
||||
@@ -222,7 +372,172 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
return c.parseLRCLibResponse(&results[0]), nil
|
||||
}
|
||||
|
||||
// findBestMatch finds the best matching lyrics based on duration and sync status
|
||||
func parseSpotifyLyricsTimeTagToMs(tag string) int64 {
|
||||
raw := strings.TrimSpace(tag)
|
||||
raw = strings.TrimPrefix(raw, "[")
|
||||
raw = strings.TrimSuffix(raw, "]")
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return ms
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`)
|
||||
matches := re.FindStringSubmatch(raw)
|
||||
if len(matches) != 4 {
|
||||
return 0
|
||||
}
|
||||
|
||||
minutes, _ := strconv.ParseInt(matches[1], 10, 64)
|
||||
seconds, _ := strconv.ParseInt(matches[2], 10, 64)
|
||||
fraction := matches[3]
|
||||
fractionInt, _ := strconv.ParseInt(fraction, 10, 64)
|
||||
if len(fraction) == 2 {
|
||||
fractionInt *= 10
|
||||
} else if len(fraction) == 1 {
|
||||
fractionInt *= 100
|
||||
}
|
||||
return minutes*60*1000 + seconds*1000 + fractionInt
|
||||
}
|
||||
|
||||
func getSpotifyLyricsRateLimitUntil() time.Time {
|
||||
spotifyLyricsRateLimitMu.RLock()
|
||||
defer spotifyLyricsRateLimitMu.RUnlock()
|
||||
return spotifyLyricsRateLimitedTil
|
||||
}
|
||||
|
||||
func setSpotifyLyricsRateLimitUntil(until time.Time) {
|
||||
spotifyLyricsRateLimitMu.Lock()
|
||||
spotifyLyricsRateLimitedTil = until
|
||||
spotifyLyricsRateLimitMu.Unlock()
|
||||
}
|
||||
|
||||
func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
||||
raw := strings.TrimSpace(retryAfter)
|
||||
if raw == "" {
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
|
||||
return now.Add(time.Duration(sec) * time.Second)
|
||||
}
|
||||
|
||||
if when, err := http.ParseTime(raw); err == nil && when.After(now) {
|
||||
return when
|
||||
}
|
||||
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||
now := time.Now()
|
||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
||||
waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds()))
|
||||
return nil, fmt.Errorf(
|
||||
"Spotify Lyrics API cooldown active (%ds remaining after previous 429)",
|
||||
waitFor,
|
||||
)
|
||||
}
|
||||
|
||||
spotifyID = strings.TrimSpace(spotifyID)
|
||||
if spotifyID == "" {
|
||||
return nil, fmt.Errorf("spotify ID is empty")
|
||||
}
|
||||
if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" {
|
||||
spotifyID = parsed.ID
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
|
||||
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
result := &LyricsResponse{
|
||||
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
|
||||
SyncType: apiResp.SyncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: "",
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}
|
||||
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
result.Lines = append(result.Lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if len(result.Lines) > 1 {
|
||||
for i := 0; i < len(result.Lines)-1; i++ {
|
||||
nextStart := result.Lines[i+1].StartTimeMs
|
||||
if nextStart > result.Lines[i].StartTimeMs {
|
||||
result.Lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
last := len(result.Lines) - 1
|
||||
if result.Lines[last].EndTimeMs == 0 {
|
||||
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
|
||||
if result.SyncType == "" {
|
||||
result.SyncType = "LINE_SYNCED"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
@@ -230,11 +545,9 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
|
||||
// Check duration match if target duration is provided
|
||||
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
|
||||
|
||||
if durationMatches {
|
||||
// Prefer synced lyrics over plain
|
||||
if result.SyncedLyrics != "" && bestSynced == nil {
|
||||
bestSynced = result
|
||||
} else if result.PlainLyrics != "" && bestPlain == nil {
|
||||
@@ -243,75 +556,217 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
|
||||
}
|
||||
}
|
||||
|
||||
// Return synced first, then plain
|
||||
if bestSynced != nil {
|
||||
return bestSynced
|
||||
}
|
||||
return bestPlain
|
||||
}
|
||||
|
||||
// durationMatches checks if two durations are within tolerance
|
||||
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||
diff := math.Abs(lrcDuration - targetDuration)
|
||||
return diff <= durationToleranceSec
|
||||
}
|
||||
|
||||
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
|
||||
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
// Check cache first
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
|
||||
extManager := GetExtensionManager()
|
||||
var extensionProviders []*ExtensionProviderWrapper
|
||||
if extManager != nil {
|
||||
extensionProviders = extManager.GetLyricsProviders()
|
||||
}
|
||||
|
||||
var cachedNonExtension *LyricsResponse
|
||||
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
|
||||
isExtensionCache := strings.HasPrefix(cached.Source, "Extension:")
|
||||
if len(extensionProviders) == 0 || isExtensionCache {
|
||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||
cachedCopy := *cached
|
||||
cachedCopy.Source = cached.Source + " (cached)"
|
||||
return &cachedCopy, nil
|
||||
}
|
||||
|
||||
// If extension providers are currently enabled, don't let stale built-in cache
|
||||
// mask newly installed/activated extensions.
|
||||
cachedNonExtension = cached
|
||||
GoLog("[Lyrics] Ignoring cached non-extension lyrics because extension providers are available\n")
|
||||
}
|
||||
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
// Try exact match first
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
lyrics.Source = "LRCLIB"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
isValidResult := func(l *LyricsResponse) bool {
|
||||
return lyricsHasUsableText(l)
|
||||
}
|
||||
|
||||
// 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)"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
if len(extensionProviders) > 0 {
|
||||
for _, provider := range extensionProviders {
|
||||
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
if err != nil {
|
||||
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search with duration matching
|
||||
query := artistName + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
lyrics.Source = "LRCLIB Search"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
if cachedNonExtension != nil {
|
||||
cachedCopy := *cachedNonExtension
|
||||
cachedCopy.Source = cachedNonExtension.Source + " (cached fallback)"
|
||||
GoLog("[Lyrics] Extension providers unavailable for this track, using cached built-in lyrics\n")
|
||||
return &cachedCopy, nil
|
||||
}
|
||||
|
||||
// Search with simplified name and duration matching
|
||||
if simplifiedTrack != trackName {
|
||||
query = artistName + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
providerOrder := GetLyricsProviderOrder()
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
|
||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||
|
||||
// Cascade through all configured built-in providers
|
||||
for _, providerName := range providerOrder {
|
||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
switch providerName {
|
||||
case LyricsProviderSpotifyAPI:
|
||||
lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID)
|
||||
|
||||
case LyricsProviderLRCLIB:
|
||||
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
||||
|
||||
case LyricsProviderNetease:
|
||||
neteaseClient := NewNeteaseClient()
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
trackName,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.IncludeTranslationNetease,
|
||||
fetchOptions.IncludeRomanizationNetease,
|
||||
)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
durationSec,
|
||||
fetchOptions.IncludeTranslationNetease,
|
||||
fetchOptions.IncludeRomanizationNetease,
|
||||
)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
simplifiedTrack,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.IncludeTranslationNetease,
|
||||
fetchOptions.IncludeRomanizationNetease,
|
||||
)
|
||||
}
|
||||
|
||||
case LyricsProviderMusixmatch:
|
||||
musixmatchClient := NewMusixmatchClient()
|
||||
lyrics, err = musixmatchClient.FetchLyrics(
|
||||
trackName,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.MusixmatchLanguage,
|
||||
)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = musixmatchClient.FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
durationSec,
|
||||
fetchOptions.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
|
||||
case LyricsProviderAppleMusic:
|
||||
appleClient := NewAppleMusicClient()
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
}
|
||||
|
||||
case LyricsProviderQQMusic:
|
||||
qqClient := NewQQMusicClient()
|
||||
lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
}
|
||||
|
||||
default:
|
||||
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||
continue
|
||||
}
|
||||
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("lyrics not found from any source")
|
||||
}
|
||||
|
||||
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
|
||||
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
// 1. Exact match with primary artist
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// 2. Exact match with full artist name
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Simplified track name
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Search by query
|
||||
query := primaryArtist + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB Search"
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// 5. Search with simplified track name
|
||||
if simplifiedTrack != trackName {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("LRCLIB: no lyrics found")
|
||||
}
|
||||
|
||||
func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
|
||||
result := &LyricsResponse{
|
||||
Instrumental: resp.Instrumental,
|
||||
@@ -349,10 +804,20 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve Apple/QQ background vocal tags by attaching them to
|
||||
// the previous timed line. This keeps [bg:...] in final exported LRC.
|
||||
if strings.HasPrefix(line, "[bg:") && len(lines) > 0 {
|
||||
lines[len(lines)-1].Words = strings.TrimSpace(lines[len(lines)-1].Words + "\n" + line)
|
||||
continue
|
||||
}
|
||||
|
||||
matches := lrcPattern.FindStringSubmatch(line)
|
||||
if len(matches) == 5 {
|
||||
startMs := lrcTimestampToMs(matches[1], matches[2], matches[3])
|
||||
words := strings.TrimSpace(matches[4])
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
@@ -373,6 +838,79 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
||||
return lines
|
||||
}
|
||||
|
||||
func plainTextLyricsLines(rawLyrics string) []LyricsLine {
|
||||
var lines []LyricsLine
|
||||
for _, line := range strings.Split(rawLyrics, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: trimmed,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func lyricsHasUsableText(lyrics *LyricsResponse) bool {
|
||||
if lyrics == nil {
|
||||
return false
|
||||
}
|
||||
if lyrics.Instrumental {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(lyrics.PlainLyrics) != "" {
|
||||
return true
|
||||
}
|
||||
for _, line := range lyrics.Lines {
|
||||
if strings.TrimSpace(line.Words) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectLyricsErrorPayload extracts human-readable error messages from
|
||||
// JSON payloads returned by lyrics proxies when no lyric is available.
|
||||
func detectLyricsErrorPayload(raw string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
lyricsKeys := []string{"lyrics", "lyric", "lrc", "content", "lines", "syncedLyrics", "unsyncedLyrics"}
|
||||
hasLyricsKey := false
|
||||
for _, key := range lyricsKeys {
|
||||
if _, ok := payload[key]; ok {
|
||||
hasLyricsKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
errorKeys := []string{"message", "error", "detail", "reason"}
|
||||
for _, key := range errorKeys {
|
||||
if msg, ok := payload[key].(string); ok {
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg != "" && !hasLyricsKey {
|
||||
return msg, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
|
||||
return "request unsuccessful", true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
||||
min, _ := strconv.ParseInt(minutes, 10, 64)
|
||||
sec, _ := strconv.ParseInt(seconds, 10, 64)
|
||||
@@ -386,43 +924,18 @@ func lrcTimestampToMs(minutes, seconds, centiseconds string) int64 {
|
||||
}
|
||||
|
||||
func msToLRCTimestamp(ms int64) string {
|
||||
return fmt.Sprintf("[%s]", msToLRCTimestampInline(ms))
|
||||
}
|
||||
|
||||
func msToLRCTimestampInline(ms int64) string {
|
||||
totalSeconds := ms / 1000
|
||||
minutes := totalSeconds / 60
|
||||
seconds := totalSeconds % 60
|
||||
centiseconds := (ms % 1000) / 10
|
||||
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||
// Use convertToLRCWithMetadata for full LRC with headers
|
||||
// Kept for potential future use
|
||||
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||
// if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
// return ""
|
||||
// }
|
||||
//
|
||||
// var builder strings.Builder
|
||||
//
|
||||
// if lyrics.SyncType == "LINE_SYNCED" {
|
||||
// for _, line := range lyrics.Lines {
|
||||
// timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
// builder.WriteString(timestamp)
|
||||
// builder.WriteString(line.Words)
|
||||
// builder.WriteString("\n")
|
||||
// }
|
||||
// } else {
|
||||
// for _, line := range lyrics.Lines {
|
||||
// builder.WriteString(line.Words)
|
||||
// builder.WriteString("\n")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return builder.String()
|
||||
// }
|
||||
|
||||
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||
// Includes [ti:], [ar:], [by:] headers
|
||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
@@ -430,13 +943,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
||||
|
||||
var builder strings.Builder
|
||||
|
||||
// Add metadata headers
|
||||
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")
|
||||
|
||||
// Add lyrics lines
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
@@ -482,6 +993,49 @@ func simplifyTrackName(name string) string {
|
||||
re := regexp.MustCompile("(?i)" + pattern)
|
||||
result = re.ReplaceAllString(result, "")
|
||||
}
|
||||
result = strings.TrimSpace(result)
|
||||
if result == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
// Add a loose fallback form for provider queries where punctuation
|
||||
// and separators differ (e.g. "/" vs "_" vs spaces).
|
||||
if loose := normalizeLooseTitle(result); loose != "" {
|
||||
return loose
|
||||
}
|
||||
|
||||
return 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Apple Music token manager — singleton with mutex for thread safety
|
||||
type appleTokenManager struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
var globalAppleTokenManager = &appleTokenManager{}
|
||||
|
||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.token != "" {
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
// Step 1: Fetch the Apple Music beta page
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Find the index JS file URL
|
||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||
match := indexJsRegex.Find(body)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||
}
|
||||
|
||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||
|
||||
// Step 3: Fetch the JS file
|
||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
jsResp, err := client.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Extract JWT token (starts with eyJh)
|
||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||
tokenMatch := tokenRegex.Find(jsBody)
|
||||
if tokenMatch == nil {
|
||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||
}
|
||||
|
||||
m.token = string(tokenMatch)
|
||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
func (m *appleTokenManager) clearToken() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.token = ""
|
||||
}
|
||||
|
||||
type appleMusicSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
URL string `json:"url"`
|
||||
Artwork struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"artwork"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
|
||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||
type paxResponse struct {
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||
}
|
||||
|
||||
type paxLyrics struct {
|
||||
Text []paxLyricDetail `json:"text"`
|
||||
Timestamp int `json:"timestamp"`
|
||||
OppositeTurn bool `json:"oppositeTurn"`
|
||||
Background bool `json:"background"`
|
||||
BackgroundText []paxLyricDetail `json:"backgroundText"`
|
||||
EndTime int `json:"endtime"`
|
||||
}
|
||||
|
||||
type paxLyricDetail struct {
|
||||
Text string `json:"text"`
|
||||
Part bool `json:"part"`
|
||||
Timestamp *int `json:"timestamp"`
|
||||
EndTime *int `json:"endtime"`
|
||||
}
|
||||
|
||||
func NewAppleMusicClient() *AppleMusicClient {
|
||||
return &AppleMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music token error: %w", err)
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||
encodedQuery,
|
||||
)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
globalAppleTokenManager.clearToken()
|
||||
return "", fmt.Errorf("apple music token expired")
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||
return "", fmt.Errorf("no songs found on apple music")
|
||||
}
|
||||
|
||||
return searchResp.Results.Songs.Data[0].ID, nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
||||
|
||||
req, err := http.NewRequest("GET", lyricsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music lyrics fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||
}
|
||||
|
||||
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyStr == "" {
|
||||
return "", fmt.Errorf("empty lyrics response from apple music")
|
||||
}
|
||||
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
var paxResp paxResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
var directLyrics []paxLyrics
|
||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to parse pax lyrics response")
|
||||
}
|
||||
|
||||
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||
lastStart := ""
|
||||
|
||||
for _, syllable := range details {
|
||||
if syllable.Timestamp != nil {
|
||||
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
|
||||
if start != lastStart {
|
||||
builder.WriteString(start)
|
||||
lastStart = start
|
||||
}
|
||||
}
|
||||
|
||||
builder.WriteString(syllable.Text)
|
||||
if !syllable.Part {
|
||||
builder.WriteString(" ")
|
||||
}
|
||||
|
||||
if syllable.EndTime != nil {
|
||||
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
for i, line := range content {
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
timestamp := msToLRCTimestamp(int64(line.Timestamp))
|
||||
|
||||
if strings.EqualFold(lyricsType, "Syllable") {
|
||||
sb.WriteString(timestamp)
|
||||
if multiPersonWordByWord {
|
||||
if line.OppositeTurn {
|
||||
sb.WriteString("v2:")
|
||||
} else {
|
||||
sb.WriteString("v1:")
|
||||
}
|
||||
}
|
||||
|
||||
appendPaxLyricDetail(&sb, line.Text)
|
||||
|
||||
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
|
||||
sb.WriteString("\n[bg:")
|
||||
appendPaxLyricDetail(&sb, line.BackgroundText)
|
||||
sb.WriteString("]")
|
||||
}
|
||||
} else {
|
||||
if len(line.Text) > 0 {
|
||||
sb.WriteString(timestamp)
|
||||
sb.WriteString(line.Text[0].Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
|
||||
func (c *AppleMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.FetchLyricsByID(songID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to parse as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Apple Music",
|
||||
Source: "Apple Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fall back to plain text if no timestamps found
|
||||
resultLines := plainTextLyricsLines(lrcText)
|
||||
|
||||
if len(resultLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: resultLines,
|
||||
SyncType: "UNSYNCED",
|
||||
Provider: "Apple Music",
|
||||
Source: "Apple Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on apple music")
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
|
||||
// The proxy handles Musixmatch authentication internally.
|
||||
type MusixmatchClient struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type musixmatchSearchResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Artwork string `json:"artwork"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
Duration int `json:"duration"`
|
||||
URL string `json:"url"`
|
||||
AlbumID int64 `json:"albumId"`
|
||||
HasSyncedLyrics bool `json:"hasSyncedLyrics"`
|
||||
HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"`
|
||||
AvailableLanguages []string `json:"availableLanguages"`
|
||||
OriginalLanguage string `json:"originalLanguage"`
|
||||
SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"`
|
||||
UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"`
|
||||
}
|
||||
|
||||
type musixmatchLyricsResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Duration int `json:"duration"`
|
||||
Language string `json:"language"`
|
||||
UpdatedTime string `json:"updatedTime"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
baseURL: "http://158.180.60.95",
|
||||
}
|
||||
}
|
||||
|
||||
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return nil, fmt.Errorf("empty track or artist name")
|
||||
}
|
||||
|
||||
encodedArtist := url.QueryEscape(artistName)
|
||||
encodedTrack := url.QueryEscape(trackName)
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if songID <= 0 || lang == "" {
|
||||
return nil, fmt.Errorf("invalid song id or language")
|
||||
}
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||
}
|
||||
|
||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||
if localizedErr == nil {
|
||||
return localized, nil
|
||||
}
|
||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||
// This is a direct public API — no proxy dependency.
|
||||
type NeteaseClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type neteaseSearchResponse struct {
|
||||
Result struct {
|
||||
Songs []struct {
|
||||
Name string `json:"name"`
|
||||
ID int64 `json:"id"`
|
||||
Artists []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
} `json:"songs"`
|
||||
SongCount int `json:"songCount"`
|
||||
} `json:"result"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type neteaseLyricsResponse struct {
|
||||
LRC *neteaseLyricField `json:"lrc"`
|
||||
TLyric *neteaseLyricField `json:"tlyric"`
|
||||
RomaLRC *neteaseLyricField `json:"romalrc"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type neteaseLyricField struct {
|
||||
Lyric string `json:"lyric"`
|
||||
}
|
||||
|
||||
var neteaseHeaders = map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "max-age=0",
|
||||
}
|
||||
|
||||
func NewNeteaseClient() *NeteaseClient {
|
||||
return &NeteaseClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Netease and returns the song ID.
|
||||
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return 0, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "http://music.163.com/api/search/pc"
|
||||
params := url.Values{}
|
||||
params.Set("s", query)
|
||||
params.Set("type", "1")
|
||||
params.Set("limit", "1")
|
||||
params.Set("offset", "0")
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("netease search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return 0, fmt.Errorf("netease search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp neteaseSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to decode netease search: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
|
||||
return 0, fmt.Errorf("no songs found on netease")
|
||||
}
|
||||
|
||||
return searchResp.Result.Songs[0].ID, nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||
params := url.Values{}
|
||||
params.Set("id", fmt.Sprintf("%d", songID))
|
||||
params.Set("lv", "1")
|
||||
params.Set("tv", "1")
|
||||
params.Set("rv", "1")
|
||||
|
||||
fullURL := lyricsURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("netease lyrics fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("netease lyrics returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var lyricsResp neteaseLyricsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lyricsResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode netease lyrics: %w", err)
|
||||
}
|
||||
|
||||
if lyricsResp.LRC == nil || strings.TrimSpace(lyricsResp.LRC.Lyric) == "" {
|
||||
return "", fmt.Errorf("no lyrics available on netease")
|
||||
}
|
||||
|
||||
lyric := lyricsResp.LRC.Lyric
|
||||
|
||||
if includeTranslation && lyricsResp.TLyric != nil && strings.TrimSpace(lyricsResp.TLyric.Lyric) != "" {
|
||||
lyric += "\n\n" + lyricsResp.TLyric.Lyric
|
||||
}
|
||||
|
||||
if includeRomanization && lyricsResp.RomaLRC != nil && strings.TrimSpace(lyricsResp.RomaLRC.Lyric) != "" {
|
||||
lyric += "\n\n" + lyricsResp.RomaLRC.Lyric
|
||||
}
|
||||
|
||||
return lyric, nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches for a track and returns parsed LyricsResponse.
|
||||
func (c *NeteaseClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
includeTranslation,
|
||||
includeRomanization bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lrcText, err := c.FetchLyricsByID(songID, includeTranslation, includeRomanization)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) == 0 {
|
||||
// May be plain text lyrics without timestamps
|
||||
plainLines := strings.Split(lrcText, "\n")
|
||||
for _, line := range plainLines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: trimmed,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("netease returned empty lyrics")
|
||||
}
|
||||
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
Provider: "Netease",
|
||||
Source: "Netease",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Netease",
|
||||
Source: "Netease",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type qqMusicSearchResponse struct {
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
Title string `json:"title"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// QQ Music lyrics request payload for paxsenix proxy
|
||||
type qqLyricsPayload struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusicClient {
|
||||
return &QQMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||
params := url.Values{}
|
||||
params.Set("format", "json")
|
||||
params.Set("inCharset", "utf8")
|
||||
params.Set("outCharset", "utf8")
|
||||
params.Set("platform", "yqq.json")
|
||||
params.Set("new_json", "1")
|
||||
params.Set("w", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
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.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp qqMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||
}
|
||||
|
||||
if len(searchResp.Data.Song.List) == 0 {
|
||||
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||
}
|
||||
|
||||
song := searchResp.Data.Song.List[0]
|
||||
|
||||
var artists []string
|
||||
for _, singer := range song.Singer {
|
||||
artists = append(artists, singer.Name)
|
||||
}
|
||||
|
||||
return &qqLyricsPayload{
|
||||
Artist: artists,
|
||||
Album: song.Album.Name,
|
||||
ID: song.ID,
|
||||
Title: song.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qqmusic lyrics fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("qqmusic lyrics proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read lyrics response: %w", err)
|
||||
}
|
||||
|
||||
bodyStr := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyStr == "" {
|
||||
return "", fmt.Errorf("empty lyrics response from qqmusic")
|
||||
}
|
||||
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
payload, err := c.searchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to use as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "QQ Music",
|
||||
Source: "QQ Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
resultLines := plainTextLyricsLines(lrcText)
|
||||
|
||||
if len(resultLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: resultLines,
|
||||
SyncType: "UNSYNCED",
|
||||
Provider: "QQ Music",
|
||||
Source: "QQ Music",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on qqmusic")
|
||||
}
|
||||
@@ -1,17 +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
|
||||
@@ -24,12 +107,13 @@ type Metadata struct {
|
||||
ISRC string
|
||||
Description string
|
||||
Lyrics string
|
||||
Genre string // Music genre (e.g., "Rock", "Pop", "Electronic")
|
||||
Label string // Record label (ORGANIZATION tag in Vorbis)
|
||||
Copyright string // Copyright information
|
||||
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 {
|
||||
@@ -97,6 +181,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
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
|
||||
@@ -116,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -138,8 +223,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
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 {
|
||||
@@ -207,6 +290,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
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
|
||||
@@ -221,25 +312,17 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -294,6 +377,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -337,7 +426,39 @@ func fileExists(path string) bool {
|
||||
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 {
|
||||
@@ -375,11 +496,9 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation
|
||||
// This is used for extension downloads where the file is already downloaded
|
||||
func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||
if genre == "" && label == "" {
|
||||
return nil // Nothing to embed
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filePath)
|
||||
@@ -422,45 +541,120 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
|
||||
if strings.HasSuffix(lower, ".flac") {
|
||||
lyrics, err := extractLyricsFromFlac(filePath)
|
||||
if err == nil && strings.TrimSpace(lyrics) != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".mp3") {
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||
meta, err := ReadOggVorbisComments(filePath)
|
||||
if err == nil && meta != nil {
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
if strings.TrimSpace(base) == "" {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
lrcPath := base + ".lrc"
|
||||
data, err := os.ReadFile(lrcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
lyrics := strings.TrimSpace(string(data))
|
||||
if lyrics == "" {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if meta.Type != flac.VorbisComment {
|
||||
continue
|
||||
}
|
||||
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
// AudioQuality represents audio quality info from a FLAC 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"`
|
||||
}
|
||||
|
||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||
// For M4A files, it delegates to GetM4AQuality
|
||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -520,305 +714,180 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// M4A (MP4/AAC) Metadata Embedding
|
||||
// ========================================
|
||||
|
||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||
}
|
||||
|
||||
moovPos := findAtom(data, "moov", 0)
|
||||
if moovPos < 0 {
|
||||
return fmt.Errorf("moov atom not found in M4A file")
|
||||
}
|
||||
|
||||
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
|
||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||
|
||||
metaAtom := buildMetaAtom(metadata, coverData)
|
||||
|
||||
var newData []byte
|
||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
|
||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||
|
||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
|
||||
newData = append(newData, data[:metaPos]...)
|
||||
newData = append(newData, metaAtom...)
|
||||
newData = append(newData, data[metaPos+metaSize:]...)
|
||||
} else {
|
||||
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||
newUdtaSize := 8 + len(newUdtaContent)
|
||||
newUdta := make([]byte, 4)
|
||||
newUdta[0] = byte(newUdtaSize >> 24)
|
||||
newUdta[1] = byte(newUdtaSize >> 16)
|
||||
newUdta[2] = byte(newUdtaSize >> 8)
|
||||
newUdta[3] = byte(newUdtaSize)
|
||||
newUdta = append(newUdta, []byte("udta")...)
|
||||
newUdta = append(newUdta, newUdtaContent...)
|
||||
|
||||
newData = append(newData, data[:udtaPos]...)
|
||||
newData = append(newData, newUdta...)
|
||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||
}
|
||||
} else {
|
||||
udtaContent := metaAtom
|
||||
udtaSize := 8 + len(udtaContent)
|
||||
newUdta := make([]byte, 4)
|
||||
newUdta[0] = byte(udtaSize >> 24)
|
||||
newUdta[1] = byte(udtaSize >> 16)
|
||||
newUdta[2] = byte(udtaSize >> 8)
|
||||
newUdta[3] = byte(udtaSize)
|
||||
newUdta = append(newUdta, []byte("udta")...)
|
||||
newUdta = append(newUdta, udtaContent...)
|
||||
|
||||
insertPos := moovPos + moovSize
|
||||
newData = append(newData, data[:insertPos]...)
|
||||
newData = append(newData, newUdta...)
|
||||
newData = append(newData, data[insertPos:]...)
|
||||
}
|
||||
|
||||
newMoovSize := moovSize + len(newData) - len(data)
|
||||
newData[moovPos] = byte(newMoovSize >> 24)
|
||||
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||
newData[moovPos+3] = byte(newMoovSize)
|
||||
|
||||
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// findAtom finds an atom by name starting from offset
|
||||
func findAtom(data []byte, name string, offset int) int {
|
||||
for i := offset; i < len(data)-8; {
|
||||
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
||||
if size < 8 {
|
||||
break
|
||||
}
|
||||
atomName := string(data[i+4 : i+8])
|
||||
if atomName == name {
|
||||
return i
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
var ilst []byte
|
||||
|
||||
if metadata.Title != "" {
|
||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||
}
|
||||
|
||||
if metadata.Artist != "" {
|
||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||
}
|
||||
|
||||
if metadata.Album != "" {
|
||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||
}
|
||||
|
||||
if metadata.AlbumArtist != "" {
|
||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||
}
|
||||
|
||||
if metadata.Date != "" {
|
||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||
}
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||
}
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||
}
|
||||
|
||||
if metadata.Lyrics != "" {
|
||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||
}
|
||||
|
||||
if len(coverData) > 0 {
|
||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||
}
|
||||
|
||||
ilstSize := 8 + len(ilst)
|
||||
ilstAtom := make([]byte, 4)
|
||||
ilstAtom[0] = byte(ilstSize >> 24)
|
||||
ilstAtom[1] = byte(ilstSize >> 16)
|
||||
ilstAtom[2] = byte(ilstSize >> 8)
|
||||
ilstAtom[3] = byte(ilstSize)
|
||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||
ilstAtom = append(ilstAtom, ilst...)
|
||||
|
||||
hdlr := []byte{
|
||||
0, 0, 0, 33, // size = 33
|
||||
'h', 'd', 'l', 'r',
|
||||
0, 0, 0, 0, // version + flags
|
||||
0, 0, 0, 0, // predefined
|
||||
'm', 'd', 'i', 'r', // handler type
|
||||
'a', 'p', 'p', 'l', // manufacturer
|
||||
0, 0, 0, 0, // component flags
|
||||
0, 0, 0, 0, // component flags mask
|
||||
0, // null terminator
|
||||
}
|
||||
|
||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||
metaContent = append(metaContent, ilstAtom...)
|
||||
|
||||
metaSize := 8 + len(metaContent)
|
||||
metaAtom := make([]byte, 4)
|
||||
metaAtom[0] = byte(metaSize >> 24)
|
||||
metaAtom[1] = byte(metaSize >> 16)
|
||||
metaAtom[2] = byte(metaSize >> 8)
|
||||
metaAtom[3] = byte(metaSize)
|
||||
metaAtom = append(metaAtom, []byte("meta")...)
|
||||
metaAtom = append(metaAtom, metaContent...)
|
||||
|
||||
return metaAtom
|
||||
}
|
||||
|
||||
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||
func buildTextAtom(name, value string) []byte {
|
||||
valueBytes := []byte(value)
|
||||
|
||||
dataSize := 16 + len(valueBytes)
|
||||
dataAtom := make([]byte, 4)
|
||||
dataAtom[0] = byte(dataSize >> 24)
|
||||
dataAtom[1] = byte(dataSize >> 16)
|
||||
dataAtom[2] = byte(dataSize >> 8)
|
||||
dataAtom[3] = byte(dataSize)
|
||||
dataAtom = append(dataAtom, []byte("data")...)
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||
dataAtom = append(dataAtom, valueBytes...)
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
atom[1] = byte(atomSize >> 16)
|
||||
atom[2] = byte(atomSize >> 8)
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte(name)...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
// buildTrackNumberAtom builds trkn atom
|
||||
func buildTrackNumberAtom(track, total int) []byte {
|
||||
dataAtom := []byte{
|
||||
0, 0, 0, 24, // size
|
||||
'd', 'a', 't', 'a',
|
||||
0, 0, 0, 0, // type = implicit
|
||||
0, 0, 0, 0, // locale
|
||||
0, 0, // padding
|
||||
byte(track >> 8), byte(track), // track number
|
||||
byte(total >> 8), byte(total), // total tracks
|
||||
0, 0, // padding
|
||||
}
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
atom[1] = byte(atomSize >> 16)
|
||||
atom[2] = byte(atomSize >> 8)
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("trkn")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
// buildDiscNumberAtom builds disk atom
|
||||
func buildDiscNumberAtom(disc, total int) []byte {
|
||||
dataAtom := []byte{
|
||||
0, 0, 0, 22, // size
|
||||
'd', 'a', 't', 'a',
|
||||
0, 0, 0, 0, // type = implicit
|
||||
0, 0, 0, 0, // locale
|
||||
0, 0, // padding
|
||||
byte(disc >> 8), byte(disc), // disc number
|
||||
byte(total >> 8), byte(total), // total discs
|
||||
}
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
atom[1] = byte(atomSize >> 16)
|
||||
atom[2] = byte(atomSize >> 8)
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("disk")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
// buildCoverAtom builds covr atom with image data
|
||||
func buildCoverAtom(coverData []byte) []byte {
|
||||
imageType := byte(13) // default JPEG
|
||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||
imageType = 14 // PNG
|
||||
}
|
||||
|
||||
dataSize := 16 + len(coverData)
|
||||
dataAtom := make([]byte, 4)
|
||||
dataAtom[0] = byte(dataSize >> 24)
|
||||
dataAtom[1] = byte(dataSize >> 16)
|
||||
dataAtom[2] = byte(dataSize >> 8)
|
||||
dataAtom[3] = byte(dataSize)
|
||||
dataAtom = append(dataAtom, []byte("data")...)
|
||||
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||
dataAtom = append(dataAtom, coverData...)
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
atom[1] = byte(atomSize >> 16)
|
||||
atom[2] = byte(atomSize >> 8)
|
||||
atom[3] = byte(atomSize)
|
||||
atom = append(atom, []byte("covr")...)
|
||||
atom = append(atom, dataAtom...)
|
||||
|
||||
return atom
|
||||
}
|
||||
|
||||
// GetM4AQuality reads audio quality from M4A file
|
||||
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||
return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
moovPos := findAtom(data, "moov", 0)
|
||||
if moovPos < 0 {
|
||||
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")
|
||||
}
|
||||
|
||||
for i := moovPos; i < len(data)-20; i++ {
|
||||
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||
if i+24 < len(data) {
|
||||
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
||||
bitDepth := 16
|
||||
if string(data[i:i+4]) == "alac" {
|
||||
bitDepth = 24
|
||||
}
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
}
|
||||
}
|
||||
moovStart := moovHeader.offset
|
||||
moovEnd := moovHeader.offset + moovHeader.size
|
||||
|
||||
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
|
||||
if err != nil {
|
||||
return AudioQuality{}, err
|
||||
}
|
||||
|
||||
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
|
||||
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,8 @@
|
||||
// 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 (
|
||||
_ "golang.org/x/mobile/bind"
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
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) {
|
||||
// Never hand the original detached FD directly to a provider attempt.
|
||||
// Fallback chains may retry with another provider after a failure.
|
||||
// If the first attempt closes the original FD, its numeric ID can be
|
||||
// reused by unrelated resources and a later close may trigger fdsan abort.
|
||||
dupFD, err := dupOutputFD(outputFD)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to duplicate output fd %d: %w", outputFD, err)
|
||||
}
|
||||
if err := prepareDupFDForWrite(dupFD, outputFD); err != nil {
|
||||
_ = closeFD(dupFD)
|
||||
return nil, err
|
||||
}
|
||||
return os.NewFile(uintptr(dupFD), fmt.Sprintf("saf_fd_%d_dup_%d", outputFD, dupFD)), nil
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(outputPath)
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
// Re-open procfs fd path instead of taking ownership of raw detached fd.
|
||||
// Some SAF providers reject O_TRUNC on these descriptors with EACCES/EPERM.
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0)
|
||||
if err == nil {
|
||||
return file, nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
|
||||
return os.OpenFile(path, os.O_WRONLY, 0)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return os.Create(outputPath)
|
||||
}
|
||||
|
||||
func prepareDupFDForWrite(dupFD, originalFD int) error {
|
||||
// Best-effort reset so retries start writing from byte 0.
|
||||
if err := truncateFD(dupFD); err != nil {
|
||||
if isBestEffortTruncateError(err) {
|
||||
GoLog("[OutputFD] truncate not supported on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
|
||||
} else {
|
||||
return fmt.Errorf("failed to truncate output fd %d (dup of %d): %w", dupFD, originalFD, err)
|
||||
}
|
||||
}
|
||||
if err := seekFDStart(dupFD); err != nil {
|
||||
GoLog("[OutputFD] seek reset failed on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeOwnedOutputFD(outputFD int) {
|
||||
if !isFDOutput(outputFD) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := closeFD(outputFD); err != nil {
|
||||
if !isBadFD(err) {
|
||||
GoLog("[OutputFD] failed to close detached fd %d: %v\n", outputFD, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
GoLog("[OutputFD] closed detached fd %d\n", outputFD)
|
||||
}
|
||||
|
||||
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,35 @@
|
||||
//go:build !windows
|
||||
|
||||
package gobackend
|
||||
|
||||
import "syscall"
|
||||
|
||||
func dupOutputFD(fd int) (int, error) {
|
||||
return syscall.Dup(fd)
|
||||
}
|
||||
|
||||
func truncateFD(fd int) error {
|
||||
return syscall.Ftruncate(fd, 0)
|
||||
}
|
||||
|
||||
func seekFDStart(fd int) error {
|
||||
_, err := syscall.Seek(fd, 0, 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func closeFD(fd int) error {
|
||||
return syscall.Close(fd)
|
||||
}
|
||||
|
||||
func isBestEffortTruncateError(err error) bool {
|
||||
switch err {
|
||||
case syscall.EPERM, syscall.EACCES, syscall.EINVAL, syscall.ESPIPE, syscall.ENOSYS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isBadFD(err error) bool {
|
||||
return err == syscall.EBADF
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build windows
|
||||
|
||||
package gobackend
|
||||
|
||||
func dupOutputFD(fd int) (int, error) {
|
||||
// Windows build is primarily for local tooling/tests.
|
||||
// Android runtime uses the !windows implementation.
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
func truncateFD(fd int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func seekFDStart(fd int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeFD(fd int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isBestEffortTruncateError(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBadFD(err error) bool {
|
||||
return false
|
||||
}
|
||||
@@ -1,28 +1,24 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// ISRC to Track ID Cache
|
||||
// ========================================
|
||||
|
||||
// TrackIDCacheEntry holds cached track ID with metadata
|
||||
type TrackIDCacheEntry struct {
|
||||
TidalTrackID int64
|
||||
QobuzTrackID int64
|
||||
AmazonTrackID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// TrackIDCache caches ISRC to track ID mappings
|
||||
type TrackIDCache struct {
|
||||
cache map[string]*TrackIDCacheEntry
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
cache map[string]*TrackIDCacheEntry
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
lastCleanup time.Time
|
||||
cleanupInterval time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -30,30 +26,48 @@ var (
|
||||
trackIDCacheOnce sync.Once
|
||||
)
|
||||
|
||||
// GetTrackIDCache returns the global track ID cache
|
||||
func GetTrackIDCache() *TrackIDCache {
|
||||
trackIDCacheOnce.Do(func() {
|
||||
globalTrackIDCache = &TrackIDCache{
|
||||
cache: make(map[string]*TrackIDCacheEntry),
|
||||
ttl: 30 * time.Minute,
|
||||
cache: make(map[string]*TrackIDCacheEntry),
|
||||
ttl: 30 * time.Minute,
|
||||
cleanupInterval: 5 * time.Minute,
|
||||
}
|
||||
})
|
||||
return globalTrackIDCache
|
||||
}
|
||||
|
||||
// Get retrieves a cached entry by ISRC
|
||||
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists || time.Now().After(entry.ExpiresAt) {
|
||||
if !exists {
|
||||
c.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
return entry
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetTidal caches Tidal track ID for an ISRC
|
||||
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -64,10 +78,15 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.TidalTrackID = trackID
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// SetQobuz caches Qobuz track ID for an ISRC
|
||||
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -78,42 +97,27 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.QobuzTrackID = trackID
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
}
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
// SetAmazon caches Amazon track ID for an ISRC
|
||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists {
|
||||
entry = &TrackIDCacheEntry{}
|
||||
c.cache[isrc] = entry
|
||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||
c.pruneExpiredLocked(now)
|
||||
c.lastCleanup = now
|
||||
}
|
||||
entry.AmazonTrackID = trackID
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
}
|
||||
|
||||
// Clear removes all cached entries
|
||||
func (c *TrackIDCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||
}
|
||||
|
||||
// Size returns the number of cached entries
|
||||
func (c *TrackIDCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parallel Download Helper
|
||||
// ========================================
|
||||
|
||||
// ParallelDownloadResult holds results from parallel operations
|
||||
type ParallelDownloadResult struct {
|
||||
CoverData []byte
|
||||
LyricsData *LyricsResponse
|
||||
@@ -122,9 +126,6 @@ type ParallelDownloadResult struct {
|
||||
LyricsErr error
|
||||
}
|
||||
|
||||
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
||||
// This runs while the main audio download is happening
|
||||
// durationMs: track duration in milliseconds for lyrics matching
|
||||
func FetchCoverAndLyricsParallel(
|
||||
coverURL string,
|
||||
maxQualityCover bool,
|
||||
@@ -136,43 +137,40 @@ func FetchCoverAndLyricsParallel(
|
||||
) *ParallelDownloadResult {
|
||||
result := &ParallelDownloadResult{}
|
||||
var wg sync.WaitGroup
|
||||
var resultMu sync.Mutex
|
||||
|
||||
if coverURL != "" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
fmt.Println("[Parallel] Starting cover download...")
|
||||
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.CoverErr = err
|
||||
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
|
||||
} else {
|
||||
result.CoverData = data
|
||||
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
// Fetch lyrics in parallel
|
||||
if embedLyrics {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
fmt.Println("[Parallel] Starting lyrics fetch...")
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.LyricsErr = err
|
||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
result.LyricsData = lyrics
|
||||
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||
} else {
|
||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||
fmt.Println("[Parallel] No lyrics found")
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -180,33 +178,28 @@ func FetchCoverAndLyricsParallel(
|
||||
return result
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Pre-warm Cache for Album/Playlist
|
||||
// ========================================
|
||||
|
||||
// PreWarmCacheRequest represents a track to pre-warm cache for
|
||||
type PreWarmCacheRequest struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
SpotifyID string // Needed for Amazon (SongLink lookup)
|
||||
Service string // "tidal", "qobuz", "amazon"
|
||||
SpotifyID string
|
||||
Service string
|
||||
}
|
||||
|
||||
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
|
||||
// This runs in background while user is viewing the track list
|
||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
if len(requests) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
||||
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
|
||||
}
|
||||
@@ -214,22 +207,19 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
wg.Add(1)
|
||||
go func(r PreWarmCacheRequest) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{} // Acquire
|
||||
defer func() { <-semaphore }() // Release
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
switch r.Service {
|
||||
case "tidal":
|
||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||
case "qobuz":
|
||||
preWarmQobuzCache(r.ISRC)
|
||||
case "amazon":
|
||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||
}
|
||||
}(req)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||
}
|
||||
|
||||
func preWarmTidalCache(isrc, _, _ string) {
|
||||
@@ -237,48 +227,66 @@ func preWarmTidalCache(isrc, _, _ string) {
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func preWarmQobuzCache(isrc string) {
|
||||
// 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) {
|
||||
if spotifyID != "" {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.Amazon {
|
||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Exported Functions for Flutter
|
||||
// ========================================
|
||||
|
||||
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
|
||||
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
||||
func PreWarmCache(tracksJSON string) error {
|
||||
var requests []PreWarmCacheRequest
|
||||
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
|
||||
}
|
||||
|
||||
// ClearTrackCache clears the track ID cache
|
||||
func ClearTrackCache() {
|
||||
GetTrackIDCache().Clear()
|
||||
fmt.Println("[Cache] Track ID cache cleared")
|
||||
}
|
||||
|
||||
// GetCacheSize returns the current cache size
|
||||
func GetCacheSize() int {
|
||||
return GetTrackIDCache().Size()
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadProgress represents current download progress
|
||||
// Now unified - returns data from multi-progress system
|
||||
type DownloadProgress struct {
|
||||
CurrentFile string `json:"current_file"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -15,21 +13,19 @@ type DownloadProgress struct {
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ItemProgress represents progress for a single download item
|
||||
type ItemProgress struct {
|
||||
ItemID string `json:"item_id"`
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||
Progress float64 `json:"progress"`
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// MultiProgress holds progress for multiple concurrent downloads
|
||||
type MultiProgress struct {
|
||||
Items map[string]*ItemProgress `json:"items"`
|
||||
}
|
||||
@@ -38,12 +34,10 @@ var (
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
// Multi-download progress tracking (unified system)
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getProgress returns current download progress from multi-progress system
|
||||
func getProgress() DownloadProgress {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -62,7 +56,6 @@ func getProgress() DownloadProgress {
|
||||
return DownloadProgress{}
|
||||
}
|
||||
|
||||
// GetMultiProgress returns progress for all active downloads as JSON
|
||||
func GetMultiProgress() string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -74,7 +67,6 @@ func GetMultiProgress() string {
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// GetItemProgress returns progress for a specific item as JSON
|
||||
func GetItemProgress(itemID string) string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -86,7 +78,6 @@ func GetItemProgress(itemID string) string {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
// StartItemProgress initializes progress tracking for an item
|
||||
func StartItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -101,7 +92,6 @@ func StartItemProgress(itemID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemBytesTotal sets total bytes for an item
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -111,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemBytesReceived sets bytes received for an item
|
||||
func SetItemBytesReceived(itemID string, received int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -124,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
||||
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -138,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
||||
}
|
||||
}
|
||||
|
||||
// CompleteItemProgress marks an item as complete
|
||||
func CompleteItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -150,7 +137,6 @@ func CompleteItemProgress(itemID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemProgress sets progress for an item directly
|
||||
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -166,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
||||
}
|
||||
}
|
||||
|
||||
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||
func SetItemFinalizing(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -177,7 +162,6 @@ func SetItemFinalizing(itemID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveItemProgress removes progress tracking for an item
|
||||
func RemoveItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -185,7 +169,6 @@ func RemoveItemProgress(itemID string) {
|
||||
delete(multiProgress.Items, itemID)
|
||||
}
|
||||
|
||||
// ClearAllItemProgress clears all item progress
|
||||
func ClearAllItemProgress() {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
@@ -193,7 +176,6 @@ func ClearAllItemProgress() {
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
}
|
||||
|
||||
// setDownloadDir sets the default download directory
|
||||
func setDownloadDir(path string) error {
|
||||
downloadDirMu.Lock()
|
||||
defer downloadDirMu.Unlock()
|
||||
@@ -201,28 +183,18 @@ func setDownloadDir(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDownloadDir returns the default download directory
|
||||
// Kept for potential future use
|
||||
// func getDownloadDir() string {
|
||||
// downloadDirMu.RLock()
|
||||
// defer downloadDirMu.RUnlock()
|
||||
// return downloadDir
|
||||
// }
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
itemID string
|
||||
current int64
|
||||
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||
startTime time.Time // Track start time for speed calculation
|
||||
lastTime time.Time // Track last update time for speed calculation
|
||||
lastBytes int64 // Track bytes at last speed calculation
|
||||
lastReported int64
|
||||
startTime time.Time
|
||||
lastTime time.Time
|
||||
lastBytes int64
|
||||
}
|
||||
|
||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||
const progressUpdateThreshold = 64 * 1024
|
||||
|
||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||
now := time.Now()
|
||||
return &ItemProgressWriter{
|
||||
@@ -236,7 +208,6 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||
return 0, ErrDownloadCancelled
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
|
||||
body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`)
|
||||
|
||||
info, err := extractQobuzDownloadInfoFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if info.DownloadURL != "https://example.test/new.flac" {
|
||||
t.Fatalf("unexpected URL: %q", info.DownloadURL)
|
||||
}
|
||||
if info.BitDepth != 24 {
|
||||
t.Fatalf("unexpected bit depth: %d", info.BitDepth)
|
||||
}
|
||||
if info.SampleRate != 96000 {
|
||||
t.Fatalf("unexpected sample rate: %d", info.SampleRate)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns detail error", func(t *testing.T) {
|
||||
body := []byte(`{"detail":"Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']" {
|
||||
t.Fatalf("expected detail error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeQobuzQualityCode(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "6",
|
||||
"5": "6",
|
||||
"6": "6",
|
||||
"cd": "6",
|
||||
"lossless": "6",
|
||||
"7": "7",
|
||||
"hi-res": "7",
|
||||
"27": "27",
|
||||
"hi-res-max": "27",
|
||||
"unexpected": "6",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := normalizeQobuzQualityCode(input); got != want {
|
||||
t.Fatalf("normalizeQobuzQualityCode(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQobuzDebugKey(t *testing.T) {
|
||||
got := getQobuzDebugKey()
|
||||
if len(got) != len(qobuzDebugKeyObfuscated) {
|
||||
t.Fatalf("unexpected debug key length: %d", len(got))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] {
|
||||
t.Fatalf("unexpected debug key reconstruction at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzAvailableProviders(t *testing.T) {
|
||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||
if len(providers) != 3 {
|
||||
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"musicdl": qobuzAPIKindMusicDL,
|
||||
"dabmusic": qobuzAPIKindStandard,
|
||||
"deeb": qobuzAPIKindStandard,
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
wantKind, ok := want[provider.Name]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected provider %q", provider.Name)
|
||||
}
|
||||
if provider.Kind != wantKind {
|
||||
t.Fatalf("provider %q has kind %q, want %q", provider.Name, provider.Kind, wantKind)
|
||||
}
|
||||
delete(want, provider.Name)
|
||||
}
|
||||
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("missing providers: %v", want)
|
||||
}
|
||||
}
|
||||
@@ -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,8 +20,6 @@ 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()
|
||||
@@ -52,7 +48,6 @@ func (r *RateLimiter) WaitForSlot() {
|
||||
r.timestamps = append(r.timestamps, time.Now())
|
||||
}
|
||||
|
||||
// cleanOldTimestamps removes timestamps that are outside the current window
|
||||
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
||||
cutoff := now.Add(-r.window)
|
||||
validStart := 0
|
||||
@@ -70,8 +65,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()
|
||||
@@ -87,7 +80,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()
|
||||
@@ -99,7 +91,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,7 +5,6 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Hiragana to Romaji mapping
|
||||
var hiraganaToRomaji = map[rune]string{
|
||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
}
|
||||
|
||||
// Katakana to Romaji mapping
|
||||
var katakanaToRomaji = map[rune]string{
|
||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
|
||||
'ヴ': "vu",
|
||||
}
|
||||
|
||||
// Combination mappings for きゃ, しゃ, etc.
|
||||
var combinationHiragana = map[string]string{
|
||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
}
|
||||
|
||||
// ContainsJapanese checks if a string contains Japanese characters
|
||||
func ContainsJapanese(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||
@@ -114,8 +110,6 @@ func isKanji(r rune) bool {
|
||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||
}
|
||||
|
||||
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||
func JapaneseToRomaji(text string) string {
|
||||
if !ContainsJapanese(text) {
|
||||
return text
|
||||
@@ -175,21 +169,16 @@ func JapaneseToRomaji(text string) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// BuildSearchQuery creates a search query from track name and artist
|
||||
// Converts Japanese to romaji if present
|
||||
func BuildSearchQuery(trackName, artistName string) string {
|
||||
// Convert Japanese to romaji
|
||||
trackRomaji := JapaneseToRomaji(trackName)
|
||||
artistRomaji := JapaneseToRomaji(artistName)
|
||||
|
||||
// Clean up the query - remove special characters that might interfere with search
|
||||
trackClean := cleanSearchQuery(trackRomaji)
|
||||
artistClean := cleanSearchQuery(artistRomaji)
|
||||
|
||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||
}
|
||||
|
||||
// cleanSearchQuery removes special characters that might interfere with search
|
||||
func cleanSearchQuery(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
@@ -202,21 +191,16 @@ func cleanSearchQuery(s string) string {
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||
// This is useful for creating search queries that work better with Tidal's search
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,139 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
Deezer bool `json:"deezer"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_url,omitempty"`
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
DeezerID string `json:"deezer_id,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"`
|
||||
}
|
||||
|
||||
var (
|
||||
globalSongLinkClient *SongLinkClient
|
||||
songLinkClientOnce sync.Once
|
||||
songLinkRegion = "US"
|
||||
songLinkRegionMu sync.RWMutex
|
||||
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||
return GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||
}
|
||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
}
|
||||
)
|
||||
|
||||
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
songLinkClientOnce.Do(func() {
|
||||
globalSongLinkClient = &SongLinkClient{
|
||||
client: NewHTTPClientWithTimeout(SongLinkTimeout),
|
||||
client: NewMetadataHTTPClient(SongLinkTimeout),
|
||||
}
|
||||
})
|
||||
return globalSongLinkClient
|
||||
}
|
||||
|
||||
// CheckTrackAvailability checks track availability on streaming platforms
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
if spotifyTrackID == "" {
|
||||
return nil, fmt.Errorf("spotify track ID is empty")
|
||||
func normalizeSongLinkRegion(region string) string {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(region))
|
||||
if len(normalized) != 2 {
|
||||
return "US"
|
||||
}
|
||||
|
||||
for _, ch := range normalized {
|
||||
if ch < 'A' || ch > 'Z' {
|
||||
return "US"
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func SetSongLinkRegion(region string) {
|
||||
normalized := normalizeSongLinkRegion(region)
|
||||
songLinkRegionMu.Lock()
|
||||
songLinkRegion = normalized
|
||||
songLinkRegionMu.Unlock()
|
||||
}
|
||||
|
||||
func GetSongLinkRegion() string {
|
||||
songLinkRegionMu.RLock()
|
||||
region := songLinkRegion
|
||||
songLinkRegionMu.RUnlock()
|
||||
return region
|
||||
}
|
||||
|
||||
func songLinkBaseURL() string {
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
if opts.AllowHTTP {
|
||||
return "http://api.song.link/v1-alpha.1/links"
|
||||
}
|
||||
return "https://api.song.link/v1-alpha.1/links"
|
||||
}
|
||||
|
||||
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
||||
if userCountry == "" {
|
||||
userCountry = GetSongLinkRegion()
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
|
||||
if userCountry != "" {
|
||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||
}
|
||||
return apiURL
|
||||
}
|
||||
|
||||
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
|
||||
if userCountry == "" {
|
||||
userCountry = GetSongLinkRegion()
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
|
||||
songLinkBaseURL(),
|
||||
url.QueryEscape(platform),
|
||||
url.QueryEscape(entityType),
|
||||
url.QueryEscape(entityID))
|
||||
if userCountry != "" {
|
||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||
}
|
||||
return apiURL
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
||||
isrc = strings.ToUpper(strings.TrimSpace(isrc))
|
||||
|
||||
switch {
|
||||
case spotifyTrackID != "":
|
||||
return s.checkTrackAvailabilityFromSpotify(spotifyTrackID)
|
||||
case isrc != "":
|
||||
return s.checkTrackAvailabilityFromISRC(isrc)
|
||||
default:
|
||||
return nil, fmt.Errorf("spotify track ID and ISRC are empty")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -106,6 +182,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
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 != "" {
|
||||
@@ -119,14 +196,72 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if isrc != "" {
|
||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||
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
|
||||
}
|
||||
|
||||
// GetStreamingURLs gets streaming URLs for a Spotify track
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
|
||||
track, err := songLinkSearchByISRC(ctx, isrc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve Deezer track from ISRC %s: %w", isrc, err)
|
||||
}
|
||||
|
||||
deezerTrackID := songLinkExtractDeezerTrackID(track)
|
||||
if deezerTrackID == "" {
|
||||
return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc)
|
||||
}
|
||||
|
||||
availability, err := songLinkCheckAvailabilityFromDeezer(s, deezerTrackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve SongLink availability from ISRC %s via Deezer %s: %w", isrc, deezerTrackID, err)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
func songLinkExtractDeezerTrackID(track *TrackMetadata) string {
|
||||
if track == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if deezerID, ok := strings.CutPrefix(strings.TrimSpace(track.SpotifyID), "deezer:"); ok {
|
||||
deezerID = strings.TrimSpace(deezerID)
|
||||
if deezerID != "" {
|
||||
return deezerID
|
||||
}
|
||||
}
|
||||
|
||||
if deezerID := extractDeezerIDFromURL(strings.TrimSpace(track.ExternalURL)); deezerID != "" {
|
||||
return deezerID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -144,41 +279,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
|
||||
return urls, 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
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return false
|
||||
}
|
||||
|
||||
var searchResp struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return searchResp.Tracks.Total > 0
|
||||
}
|
||||
|
||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||
func extractDeezerIDFromURL(deezerURL string) string {
|
||||
parts := strings.Split(deezerURL, "/")
|
||||
if len(parts) > 0 {
|
||||
@@ -191,21 +291,156 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
||||
// 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 ""
|
||||
}
|
||||
|
||||
if strings.Contains(qobuzURL, "/track/") {
|
||||
parts := strings.Split(qobuzURL, "/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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from album URL with track highlight (e.g. ?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]
|
||||
if idx := strings.Index(part, "?"); idx > 0 {
|
||||
part = part[:idx]
|
||||
}
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" && isNumeric(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||
if youtubeURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(youtubeURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// AlbumAvailability represents album availability on different platforms
|
||||
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
|
||||
}
|
||||
|
||||
type AlbumAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Deezer bool `json:"deezer"`
|
||||
@@ -213,17 +448,11 @@ type AlbumAvailability struct {
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
}
|
||||
|
||||
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||
// Use global rate limiter
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
// Build API URL for album
|
||||
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))
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -269,37 +498,44 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// 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 "", err
|
||||
}
|
||||
|
||||
|
||||
if !availability.Deezer || availability.DeezerID == "" {
|
||||
return "", fmt.Errorf("album not found on Deezer")
|
||||
}
|
||||
|
||||
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Deezer ID Support - Query SongLink using Deezer as source
|
||||
// ========================================
|
||||
|
||||
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
||||
// 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")
|
||||
}
|
||||
|
||||
|
||||
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -313,7 +549,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle specific error codes
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||
}
|
||||
@@ -360,6 +595,7 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
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 != "" {
|
||||
@@ -367,14 +603,33 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
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
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
||||
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||
// entityType: "song" or "album"
|
||||
// entityID: the ID on that platform
|
||||
@@ -382,16 +637,10 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
if entityID == "" {
|
||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||
}
|
||||
|
||||
// Use global rate limiter
|
||||
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
// Build API URL using platform, type, and id parameters (as per API docs)
|
||||
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
||||
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))
|
||||
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
@@ -405,7 +654,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle specific error codes
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||
}
|
||||
@@ -443,6 +691,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
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 != "" {
|
||||
@@ -450,16 +699,35 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
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)
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
parts := strings.Split(spotifyURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
@@ -472,44 +740,138 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
||||
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
|
||||
}
|
||||
|
||||
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
apiURL := buildSongLinkURLFromTarget(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)
|
||||
}
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -9,22 +9,21 @@ 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"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
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"
|
||||
artistRelatedURL = "https://api.spotify.com/v1/artists/%s/related-artists"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
// Cache TTL settings
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
albumCacheTTL = 10 * time.Minute
|
||||
@@ -32,7 +31,6 @@ const (
|
||||
|
||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||
|
||||
// cacheEntry holds cached data with expiration
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
expiresAt time.Time
|
||||
@@ -42,80 +40,46 @@ func (e *cacheEntry) isExpired() bool {
|
||||
return time.Now().After(e.expiresAt)
|
||||
}
|
||||
|
||||
// SpotifyMetadataClient handles Spotify API interactions
|
||||
type SpotifyMetadataClient struct {
|
||||
httpClient *http.Client
|
||||
clientID string
|
||||
clientSecret string
|
||||
cachedToken string
|
||||
tokenExpiresAt time.Time
|
||||
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||
tokenMu sync.Mutex
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
|
||||
// Caches to reduce API calls
|
||||
artistCache map[string]*cacheEntry // key: artistID
|
||||
searchCache map[string]*cacheEntry // key: query+type
|
||||
albumCache map[string]*cacheEntry // key: albumID
|
||||
artistCache map[string]*cacheEntry
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
cacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
// Custom credentials storage (set from Flutter)
|
||||
var (
|
||||
customClientID string
|
||||
customClientSecret string
|
||||
credentialsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||
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)")
|
||||
var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead")
|
||||
|
||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
credentialsMu.Lock()
|
||||
defer credentialsMu.Unlock()
|
||||
customClientID = clientID
|
||||
customClientSecret = clientSecret
|
||||
customClientID = ""
|
||||
customClientSecret = ""
|
||||
}
|
||||
|
||||
// HasSpotifyCredentials checks if Spotify credentials are configured
|
||||
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
|
||||
}
|
||||
|
||||
// getCredentials returns the current credentials or error if not configured
|
||||
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
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
// Returns error if credentials are not configured
|
||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
clientID, clientSecret, err := getCredentials()
|
||||
if err != nil {
|
||||
@@ -125,7 +89,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
rng: rand.New(src),
|
||||
@@ -137,7 +101,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TrackMetadata represents track information
|
||||
type TrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
@@ -152,10 +115,11 @@ type TrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
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"`
|
||||
@@ -172,28 +136,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"` // album, single, ep, compilation
|
||||
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"` // Music genre(s), comma-separated
|
||||
Label string `json:"label,omitempty"` // Record label name
|
||||
Copyright string `json:"copyright,omitempty"` // Copyright information
|
||||
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"`
|
||||
@@ -205,13 +167,11 @@ 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"`
|
||||
}
|
||||
|
||||
// ArtistInfoMetadata holds artist information
|
||||
type ArtistInfoMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -220,35 +180,30 @@ type ArtistInfoMetadata struct {
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
// ArtistAlbumMetadata holds album info for artist discography
|
||||
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"` // album, single, compilation
|
||||
AlbumType string `json:"album_type"`
|
||||
Artists string `json:"artists"`
|
||||
}
|
||||
|
||||
// ArtistResponsePayload is the response for artist requests
|
||||
type ArtistResponsePayload struct {
|
||||
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||
}
|
||||
|
||||
// TrackResponse is the response for single track requests
|
||||
type TrackResponse struct {
|
||||
Track TrackMetadata `json:"track"`
|
||||
}
|
||||
|
||||
// SearchResult represents search results
|
||||
type SearchResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// SearchArtistResult represents an artist in search results
|
||||
type SearchArtistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -257,10 +212,29 @@ type SearchArtistResult struct {
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
// SearchAllResult represents combined search results for tracks and artists
|
||||
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"`
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
Albums []SearchAlbumResult `json:"albums"`
|
||||
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||
}
|
||||
|
||||
type spotifyURI struct {
|
||||
@@ -274,7 +248,6 @@ type accessTokenResponse struct {
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// Internal API response types
|
||||
type image struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -300,7 +273,7 @@ type albumSimplified struct {
|
||||
Images []image `json:"images"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
AlbumType string `json:"album_type"` // album, single, compilation
|
||||
AlbumType string `json:"album_type"`
|
||||
}
|
||||
|
||||
type trackFull struct {
|
||||
@@ -315,7 +288,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 {
|
||||
@@ -341,7 +313,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -367,6 +338,10 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
}
|
||||
|
||||
for _, track := range response.Tracks.Items {
|
||||
var firstArtistID string
|
||||
if len(track.Artists) > 0 {
|
||||
firstArtistID = track.Artists[0].ID
|
||||
}
|
||||
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||
SpotifyID: track.ID,
|
||||
Artists: joinArtists(track.Artists),
|
||||
@@ -381,6 +356,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumID: track.Album.ID,
|
||||
ArtistID: firstArtistID,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
@@ -388,7 +365,6 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SearchAll searches for tracks and artists on Spotify
|
||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
@@ -433,6 +409,10 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
}
|
||||
|
||||
for _, track := range response.Tracks.Items {
|
||||
var firstArtistID string
|
||||
if len(track.Artists) > 0 {
|
||||
firstArtistID = track.Artists[0].ID
|
||||
}
|
||||
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||
SpotifyID: track.ID,
|
||||
Artists: joinArtists(track.Artists),
|
||||
@@ -447,6 +427,8 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumID: track.Album.ID,
|
||||
ArtistID: firstArtistID,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
@@ -510,7 +492,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Track item structure for pagination
|
||||
type trackItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -538,19 +519,24 @@ 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,
|
||||
}
|
||||
|
||||
// Collect all tracks (including paginated)
|
||||
allTrackItems := data.Tracks.Items
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
// Fetch remaining tracks using pagination (no limit)
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
Items []trackItem `json:"items"`
|
||||
@@ -566,13 +552,11 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
|
||||
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||
|
||||
// Collect track IDs for parallel ISRC fetching
|
||||
trackIDs := make([]string, len(allTrackItems))
|
||||
for i, item := range allTrackItems {
|
||||
trackIDs[i] = item.ID
|
||||
}
|
||||
|
||||
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
||||
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||
@@ -612,10 +596,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
||||
// Similar to Deezer implementation for consistency
|
||||
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||
const maxParallelISRC = 10
|
||||
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
@@ -624,7 +606,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
return result
|
||||
}
|
||||
|
||||
// Use semaphore to limit concurrent requests
|
||||
sem := make(chan struct{}, maxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -633,7 +614,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
@@ -654,7 +634,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||
// First request to get playlist info and first batch of tracks
|
||||
var data struct {
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
@@ -680,10 +659,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
info.Owner.Name = data.Name
|
||||
info.Owner.Images = firstImageURL(data.Images)
|
||||
|
||||
// Pre-allocate with expected capacity
|
||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||
|
||||
// Add first batch of tracks
|
||||
for _, item := range data.Tracks.Items {
|
||||
if item.Track == nil {
|
||||
continue
|
||||
@@ -707,7 +684,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
for nextURL != "" {
|
||||
@@ -719,7 +695,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||
// Log error but return what we have so far
|
||||
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
||||
break
|
||||
}
|
||||
@@ -766,7 +741,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Fetch artist info
|
||||
var artistData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -789,7 +763,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
Popularity: artistData.Popularity,
|
||||
}
|
||||
|
||||
// Fetch artist albums (all types: album, single, compilation)
|
||||
albums := make([]ArtistAlbumMetadata, 0)
|
||||
offset := 0
|
||||
limit := 50
|
||||
@@ -829,13 +802,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
})
|
||||
}
|
||||
|
||||
// Check if there are more albums
|
||||
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
||||
break
|
||||
}
|
||||
offset += limit
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
if offset > 500 {
|
||||
break
|
||||
}
|
||||
@@ -856,6 +827,47 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Artists []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:"artists"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, fmt.Sprintf(artistRelatedURL, artistID), token, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxItems := len(data.Artists)
|
||||
if limit > 0 && limit < maxItems {
|
||||
maxItems = limit
|
||||
}
|
||||
|
||||
result := make([]SearchArtistResult, 0, maxItems)
|
||||
for i := 0; i < maxItems; i++ {
|
||||
artist := data.Artists[i]
|
||||
result = append(result, SearchArtistResult{
|
||||
ID: artist.ID,
|
||||
Name: artist.Name,
|
||||
Images: firstImageURL(artist.Images),
|
||||
Followers: artist.Followers.Total,
|
||||
Popularity: artist.Popularity,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
||||
var data struct {
|
||||
ExternalID externalID `json:"external_ids"`
|
||||
@@ -916,7 +928,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
||||
return err
|
||||
}
|
||||
|
||||
// Set headers (same as PC version baseHeaders)
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
@@ -952,16 +963,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
||||
c.rngMu.Lock()
|
||||
defer c.rngMu.Unlock()
|
||||
|
||||
// Use Mac User-Agent format (same as PC version)
|
||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||
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 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
@@ -978,7 +988,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 {
|
||||
@@ -989,13 +998,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
|
||||
@@ -1008,7 +1015,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 == "" {
|
||||
@@ -1034,7 +1040,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Skip intl- prefix if present
|
||||
if strings.HasPrefix(parts[0], "intl-") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
@@ -1042,7 +1047,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":
|
||||
@@ -1050,7 +1054,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,70 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
||||
func normalizeLooseTitle(title string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(title))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(trimmed))
|
||||
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r):
|
||||
b.WriteByte(' ')
|
||||
// Treat common separators as spaces.
|
||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
// Drop other punctuation/symbols (including emoji) for loose matching.
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func hasAlphaNumericRunes(value string) bool {
|
||||
for _, r := range value {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeSymbolOnlyTitle keeps symbol/emoji runes while dropping letters,
|
||||
// digits, spaces and punctuation. This is useful for emoji-only titles such as
|
||||
// "🪐", "🌎" etc, so we can compare them strictly and avoid false matches.
|
||||
func normalizeSymbolOnlyTitle(title string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(title))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(trimmed))
|
||||
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case unicode.IsLetter(r), unicode.IsNumber(r), unicode.IsSpace(r), unicode.IsPunct(r):
|
||||
continue
|
||||
// Drop combining marks such as emoji variation selectors.
|
||||
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||