Compare commits
1116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd750b95ca | |||
| e42e44f28b | |||
| 67daefdf60 | |||
| fabaf0a3ff | |||
| fb90c73f42 | |||
| c6cf65f075 | |||
| 25de009ebc | |||
| 8918d74bb5 | |||
| f9de8d45d9 | |||
| 48eef0853d | |||
| fc70a912bf | |||
| cd3e5b4b28 | |||
| 482ca82eb4 | |||
| 6d87ae5484 | |||
| bd3e2b999b | |||
| 186196e12b | |||
| bd73eb292d | |||
| 8ee2919934 | |||
| f29177216d | |||
| 18d3612674 | |||
| f7c0e417d7 | |||
| 3fd13e9930 | |||
| 0b20cb895e | |||
| 8979210804 | |||
| e9b24712c5 | |||
| 3d6e5615fa | |||
| fc7220b572 | |||
| 198ed5ce6f | |||
| b48462a945 | |||
| 0f327cd1f6 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 | |||
| da9d64ccfd | |||
| 02e64b7a3c | |||
| a435009d4d | |||
| 9ca73a99a6 | |||
| 4974284760 | |||
| a0306bd345 | |||
| ea7e594c68 | |||
| d00a84f1b9 | |||
| 58b6203681 | |||
| d299144c47 | |||
| 40b224e5a1 | |||
| 7021e5493f | |||
| 68bbc8a259 | |||
| be94a59441 | |||
| 3a73aee1b7 | |||
| c91154ea3e | |||
| 4f365ca7fe | |||
| 98fdc0ed7c | |||
| 12be560cb8 | |||
| 4cf885a52e | |||
| c57c8a4267 | |||
| 2ca6c737c0 | |||
| 2a451ec2a3 | |||
| 346e79b247 | |||
| 497ba342c0 | |||
| aca0bbb819 | |||
| 2df8fd6282 | |||
| 999317eba1 | |||
| 16991476ed | |||
| ba33639818 | |||
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| b2873378fc | |||
| 66a89d9e8e | |||
| 814deca19d | |||
| 3bb6754d9c | |||
| 7d11d67cd2 | |||
| c0bd10cfca | |||
| e003b15ffd | |||
| ac1c7d31c9 | |||
| 6fc9ffeb23 | |||
| 9bebed506b | |||
| bffeb55a7a | |||
| c66d13c9fd | |||
| 8529985a0e | |||
| a8a3973225 | |||
| 6710f90e1e | |||
| 929c5f3249 | |||
| f170ead7b9 | |||
| e63e366228 | |||
| 95e755e54e | |||
| c719406425 | |||
| 9627ef66cf | |||
| 15f977d98d | |||
| 5b5f043624 | |||
| 529a920b24 | |||
| 09eb6cf206 | |||
| af6fa6ea53 | |||
| 280b921755 | |||
| 6ebe0c51ce | |||
| 47bd24c1bd | |||
| 2b23678c0d | |||
| e8327545ad | |||
| 89a38af538 | |||
| b7f34ec47c | |||
| 967523bfc6 | |||
| 29d8a185f9 | |||
| 4495d4bf4e | |||
| 67737467e0 | |||
| 13845eea04 | |||
| 12779778d3 | |||
| d4178ad036 | |||
| 49ea84384d | |||
| a6d9849468 | |||
| 16100aa0fd | |||
| 387dd47374 | |||
| 6ecb69feae | |||
| feff985439 | |||
| 2e8fe34824 | |||
| f58005f406 | |||
| 75abc03a4f | |||
| 84381d142a | |||
| f67f52eba9 | |||
| 3747ffff64 | |||
| ed47efed17 | |||
| c0d72e89d7 | |||
| a4313cfe0f | |||
| c7bef03ee3 | |||
| ce5a9e0cff | |||
| 859b823e77 | |||
| 7d8cf5f7ca | |||
| 4adaed8da0 | |||
| 554fe08fcd | |||
| b8af75bf6e | |||
| 35f2f119db | |||
| f36096e0ac | |||
| 1665e4cd57 | |||
| 42f0267277 | |||
| 82f59d32b9 | |||
| 941347b007 | |||
| 739c89569f | |||
| 18607597e9 | |||
| 7bb808cba5 | |||
| 78cd396847 | |||
| bb342c01e2 | |||
| 8a5dc0edfe | |||
| 8540da484f | |||
| 20f789f8e0 | |||
| 3e89326c95 | |||
| a7ea4de25a | |||
| aabfbf062e | |||
| 7b9ed3ec8e | |||
| 6dad66d62d | |||
| 31018230ee | |||
| 54ddc1f59c | |||
| c6856bd1a1 | |||
| 8c18c7b8f1 | |||
| 10c5293f64 | |||
| d5381afcf9 | |||
| 134bf4375f | |||
| aa9854fc0a | |||
| 10bc29e347 | |||
| 733efce161 | |||
| ac9141f167 | |||
| d89850e8a9 | |||
| 5948e4f125 | |||
| 34d22f783c | |||
| c347b6999e | |||
| adc74741ce | |||
| 48f614359e | |||
| 16669d8b7a | |||
| f1eef47600 | |||
| fc1567d2c8 | |||
| fffce6039a | |||
| cbfa147a12 | |||
| 5b8c953ae6 | |||
| 37a4dc096b | |||
| b3808645fb | |||
| 24aa804bf2 | |||
| 941ffb2bb7 | |||
| 59737d6f2b | |||
| c8ad93ee9b | |||
| 8cb0c037c2 | |||
| e30b69397b | |||
| d6e837fd61 | |||
| 5c97d202b9 | |||
| 0f6cfa75bb | |||
| 91bd6d1572 | |||
| df77ae3986 | |||
| 3cd6d068a2 | |||
| 29165da5ac | |||
| 9343583c69 | |||
| d82d255bae | |||
| 93a7042a84 | |||
| 5be5c869da | |||
| 8d45e023b2 | |||
| f2ae1398db | |||
| c2736a61fb | |||
| 76fe8dbc69 | |||
| dd05061829 | |||
| 8f6b99c550 | |||
| f54ee86591 | |||
| 42e0ec2663 | |||
| 0456a97b35 | |||
| 07c609cc3a | |||
| de5d26403f | |||
| 73c2d0efac | |||
| d3c1c440cc | |||
| 94195c636f | |||
| 9abf492362 | |||
| defc84c216 | |||
| 3c9ae39145 | |||
| 64408c8d8b | |||
| db55bb4693 | |||
| 9c6856b584 | |||
| 581f43f4c1 | |||
| 221d7e4829 | |||
| 706528f04b | |||
| f95a96dd1f | |||
| d85c16ce0f | |||
| 35afdf4be4 | |||
| eb5ed86019 | |||
| 0cfa6f56be | |||
| 5af88ead33 | |||
| 8ec63ee610 | |||
| c8247bf7a0 | |||
| 2f3270c7ff | |||
| 960d60f0bc | |||
| a4899144c5 | |||
| 808083c938 | |||
| 7e41ab4460 | |||
| 75a2bec8d5 | |||
| c35857bb61 | |||
| 2c897992c5 | |||
| 7d5cb574c6 | |||
| c582f96cf6 | |||
| 8fab3f60a7 | |||
| c6e981b3a1 | |||
| f0c5c5660a | |||
| 9c647bb31b | |||
| e1e82ac586 | |||
| 585d6da98d | |||
| bc279dd7fd | |||
| f2fdead6d3 | |||
| f66ccb4741 | |||
| 32c10c2b23 | |||
| 05674d9586 | |||
| 11bda9aae5 | |||
| 02c803385c | |||
| 8fe7a1e756 | |||
| 4a61ffea8d | |||
| 91548691ad | |||
| 36a646e5c0 | |||
| f306599ab2 | |||
| 3a7b777717 | |||
| 2334e659ad | |||
| 2a0216c87a | |||
| ab2d671760 | |||
| 5532d0a7d9 | |||
| 277e7719d3 | |||
| d6cb9fc261 | |||
| e6a857335f | |||
| e82e3a8343 | |||
| 6d812c76c2 | |||
| 5af4bb7ade | |||
| 030d66fd65 | |||
| c929f8d0a6 | |||
| 6fb50cfc67 | |||
| ebcdcf40dc | |||
| 76a05e717b | |||
| 062ce31cf7 | |||
| 98abaf6635 | |||
| 8675ab3215 | |||
| ad6ef2884a | |||
| 3ebb8a5e79 | |||
| 652b1b0821 | |||
| 4747119a7f | |||
| bfd769b349 | |||
| 40c3c73bfd | |||
| 96d11b1d7d | |||
| b3771f3488 | |||
| a07c125454 | |||
| 54a7b6b568 | |||
| 77d0ac4fce | |||
| bddd733466 | |||
| e6ffb08954 | |||
| 2fe8f659bc | |||
| ab26d84632 | |||
| a202ca4865 | |||
| a2db5bef25 | |||
| a81fa1ead7 | |||
| e7315cbc7e | |||
| cd757f177f | |||
| 103c55c072 | |||
| 765caab6df | |||
| 72f4663dd5 | |||
| deb6d92b55 | |||
| 0222ea6ccb | |||
| 8c047600a0 | |||
| 57b5877fdc | |||
| 7ddf67a977 | |||
| 7af2212d11 | |||
| 5e13651ed9 | |||
| 08e9c8d463 | |||
| b3d93880b5 | |||
| 05e100a492 | |||
| a4e22de455 | |||
| c89600591c | |||
| f1d57d89c7 | |||
| 83124875d3 | |||
| 9460e9faae | |||
| 882afd938b | |||
| ab72a10578 | |||
| d76d020cfe | |||
| e39756fa3f | |||
| 8e794e1ef1 | |||
| caf68c8137 | |||
| 5161ac8f77 | |||
| 4df96db809 | |||
| 5605930aef | |||
| 85bf3cfa84 | |||
| 8eec73d88c | |||
| b63dbbbfd5 | |||
| 8b16157047 | |||
| 6628682f97 | |||
| 5971ffc470 | |||
| baf95ec328 | |||
| 0a6590fafd | |||
| 22dd0ee0f6 | |||
| f9ad6046e8 | |||
| 8a21902fa1 | |||
| 016564eda7 | |||
| 5a8ff7db37 | |||
| cc08596adf | |||
| cdc5836785 | |||
| 813ed79073 | |||
| 537bab69ab | |||
| b0871ad94b | |||
| 0bd7574ab2 | |||
| c3f8b48bf7 | |||
| 90f731ac1e | |||
| e83fd66023 | |||
| d49bab403d | |||
| 8e6cbcbc2a | |||
| a6bef63aa7 | |||
| 898e28c40c | |||
| 9fda7ef596 | |||
| 17ba1713ad | |||
| f4110204b1 | |||
| d2a183b52d | |||
| a8dcf3113c | |||
| 1f52a6c9e0 | |||
| adbed63196 | |||
| 33e20845f1 | |||
| 9a7096c301 | |||
| 4c365032ff | |||
| bbd32d40a6 | |||
| 73f4a91fa1 | |||
| 1e2e383794 | |||
| 3b70b071e3 | |||
| 838c0ea421 | |||
| 3ac9ff1dd7 | |||
| 3e90b29d2b | |||
| b74186464b | |||
| f4934dcb28 | |||
| 30973a8e78 | |||
| 9b89625660 | |||
| c70ba5962e | |||
| 8c722b0a18 | |||
| 3ece6770e1 | |||
| b39ec41255 | |||
| 1407018d98 | |||
| d4d661d6d4 | |||
| 2092f078ec | |||
| 924569aefb | |||
| a5864e15f8 | |||
| 0dc89cf569 | |||
| 3c1e9d03a0 | |||
| 28a082f47a | |||
| 38994d5900 | |||
| 472896328a | |||
| 92f408035a | |||
| 979186243c | |||
| ee66247bea | |||
| 66a9daf733 | |||
| 69a9e0cb40 | |||
| cd6beaa7d4 | |||
| 5f4ff17630 | |||
| 3c3bbe516e | |||
| a1d1ab1f0f | |||
| ab9456fff8 | |||
| 2f673469aa | |||
| 05fde22075 | |||
| deab7b7dd6 | |||
| ae5da3b6e0 | |||
| 4d0c8f49aa | |||
| 3068f4e367 | |||
| 3844704490 | |||
| 12144b8220 | |||
| b639080494 | |||
| e67d7d68cb | |||
| b8f18c1cf5 | |||
| 529958c4af | |||
| 40077a577c | |||
| e0fbd706ce | |||
| b76879f204 | |||
| 564dd8bf95 | |||
| b317f7cd76 | |||
| a3b49d2642 | |||
| 6f20620c97 | |||
| b6a055a01a | |||
| 44ac593ddc | |||
| ca4c2a661e | |||
| 8b3b39f390 | |||
| 915934e5dd | |||
| 42f15018ae | |||
| 3554a7b5b9 | |||
| f2941939b7 | |||
| 1a77ded997 | |||
| 05d25d4d7c | |||
| 7cc1fef989 | |||
| abc599d7f9 | |||
| eefbb63299 | |||
| fdbb474763 | |||
| 6a7eef6956 | |||
| 9b27e86e0f | |||
| dbe8f5d814 | |||
| 9847594ca1 | |||
| 4a966e5e52 | |||
| d8ba4549aa | |||
| 986f5eafc8 | |||
| 84df64fcfe | |||
| a9150b85b9 | |||
| 68e6c8be35 | |||
| bd42655c0e | |||
| fe1c96ea12 | |||
| bae2bf63eb | |||
| 803e0dc5a3 | |||
| 474c37ec8e | |||
| eb7726263a | |||
| f87ccc51c5 | |||
| b0b4e7803c | |||
| 450f19c656 | |||
| 55b9c08f99 | |||
| a5f3aab775 | |||
| 7442c9b106 | |||
| ae66cb478b | |||
| 2516c3e618 | |||
| 02a5893279 | |||
| bd0d653210 | |||
| 62626ddc08 | |||
| b6574f0097 | |||
| c35a8dd803 | |||
| d54b2249b6 | |||
| f7be2c1e12 | |||
| 309568becc | |||
| dd9b6dbfe3 | |||
| 4692b48174 | |||
| db82fa3ae1 | |||
| 5c42507b12 | |||
| ebe7d87da7 | |||
| 3a6b7eed59 | |||
| 51d02d7764 | |||
| df39d61ed4 | |||
| 9cd2b1d8c5 | |||
| 49f1fb43fa | |||
| 7ec5d28caf | |||
| 23f5aa11b0 | |||
| 5fdf1df5df | |||
| 65b521ff8b | |||
| 6d578694e2 | |||
| f7ec649b24 | |||
| 71a9e1baef | |||
| 4a4adcb72e | |||
| 3458f03158 | |||
| 4fe4a01840 | |||
| e5d6fddeda | |||
| 370f5e3b8b | |||
| f5bb0820d5 | |||
| feb6da3ecb | |||
| 39f28a12aa | |||
| 416fc79637 | |||
| 1f43780bec | |||
| f9dd82010f | |||
| f0790b627d | |||
| 55350fffa0 | |||
| 7229602343 | |||
| 1c81c53699 | |||
| 5256d6197b | |||
| 79a6c8cdc0 | |||
| aa3b4d7d1e | |||
| cd220a4650 | |||
| d71b2a9ab8 | |||
| a2efe7243d | |||
| e0acda14e4 | |||
| 029ab8ea47 | |||
| 38f9498006 | |||
| 67fc3e5de2 | |||
| f1e6e9253f | |||
| 11c612e270 | |||
| cec5e49659 | |||
| 1dbdb5f2c3 | |||
| 086511d3e9 | |||
| 3d366d21b7 | |||
| 35f412dbd2 | |||
| c167aa0522 | |||
| fccb3f3d78 | |||
| 3a33283e94 | |||
| c74fb28a3a | |||
| ea504cc3ed | |||
| 61a2ad258e | |||
| ab62a8b1a9 | |||
| 479eb1272d | |||
| d23562e579 | |||
| 541d64bdd0 | |||
| d4f7e6e494 | |||
| 532c08fe2e | |||
| 704b9674f4 | |||
| 3de94280d2 | |||
| 65897789f6 | |||
| 5d097c3a95 | |||
| 4023e752a0 | |||
| 9a722b1a24 | |||
| 481b4b03dc | |||
| b7fd2f7902 | |||
| f2e1e59d6a | |||
| 3af2ecf1f4 | |||
| 1b2f2c891c | |||
| 155f3259f2 | |||
| f52d8d68b8 | |||
| 216d6e152c | |||
| b6f90e727c | |||
| 790bbc544f | |||
| bd511f7dc6 | |||
| e91c8c28a8 | |||
| 3c6d1afa97 | |||
| 3947e109b4 | |||
| 37b4727a29 | |||
| 2604d0002a | |||
| cca337ab31 | |||
| bb6e766a09 | |||
| af203ae51f | |||
| 01cbdde70e | |||
| e70ed311ed | |||
| c732cddf06 | |||
| 1f71f957e2 | |||
| 757c5fab19 | |||
| cfa537db1f | |||
| 8b18bef5ab | |||
| 76b01fb837 | |||
| 219ea593dd | |||
| 5c54e04b69 | |||
| bef07b1583 | |||
| 859762e35c | |||
| ca136b8e17 | |||
| 03d29a73f7 | |||
| c6ee9cda35 | |||
| ad3fefac0b | |||
| ad606cca53 | |||
| c0a9cb756f | |||
| 5fa00c0051 | |||
| 239e073a8c | |||
| bf87662f99 | |||
| 278ebf3472 | |||
| 4273edd836 | |||
| 7ce41fc1c1 | |||
| 7ade57e010 | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| 65a152cada | |||
| fb7a576e00 | |||
| 30a559b279 | |||
| f77d5fdf14 | |||
| 0a0667889c | |||
| 14d8cd54d7 | |||
| 5fa3d405e6 | |||
| 34eb335fd0 | |||
| c910530927 | |||
| 69e1a6cf6b | |||
| bd84613624 | |||
| 0b4777fc6b | |||
| e22813caec | |||
| 8f6e8432de | |||
| e4a6177cb5 | |||
| 34ffbca3e8 | |||
| f8acd8f3b6 | |||
| 9956f051ac | |||
| b33ae905a2 | |||
| 11eb0aa12a | |||
| 7c08321ce3 | |||
| e20becdca7 | |||
| 24897e25e2 | |||
| 2dc4cef583 | |||
| 34c95fbd81 | |||
| 9071db9b88 | |||
| 3eb2fdd7fa | |||
| 99e0d3d361 | |||
| a2eb89e230 | |||
| b21e953ef1 | |||
| 0ef086ce57 | |||
| 72d45746a5 | |||
| 9c22f41a3e | |||
| 22f001a735 | |||
| 26d464d3c7 | |||
| 3d6a3f8d04 | |||
| 39ce22a9e2 | |||
| 88f9a65d11 | |||
| 663ee12bcc | |||
| b3c98cecc3 | |||
| 49a18a977b | |||
| a5d0feeedf | |||
| a574e73b44 | |||
| a66f6a739f | |||
| cc7e1b54b6 | |||
| 28cb7fcd3d | |||
| aeb370beca | |||
| 239707e2da | |||
| c1e2778735 | |||
| fb608a554d | |||
| 7561065802 | |||
| 56c8d89999 | |||
| 9192760f3c | |||
| 8c201b5b4a | |||
| 5e19178bc0 | |||
| 107d9ca007 | |||
| 423695c24d | |||
| 4633c7253a | |||
| 8ace180fa8 | |||
| b9c3f2f0dd | |||
| 81b0eede8c | |||
| eb0cdbeba8 | |||
| ee212a0e48 | |||
| 2073516666 | |||
| 9d479b61d6 | |||
| 203e6bc4eb | |||
| 5f1ffbee4e | |||
| 40ec24db69 | |||
| ba8d0a3438 | |||
| 82decf99a6 | |||
| 6ba9fc1fec | |||
| 715d94c2ed | |||
| e1a722f479 | |||
| edbe12c512 | |||
| 9fc6542792 | |||
| 4c01ee26c2 | |||
| 813b9fcf61 | |||
| fe070e0177 | |||
| 423bb87ed8 | |||
| 1641f51b0c | |||
| 3f78a1f3d1 | |||
| b29dc63337 | |||
| 29699117dc | |||
| 3c75f9ecc6 | |||
| 79340703c1 | |||
| df23e3f96c | |||
| d9f788ddeb | |||
| 62afbdcaaa | |||
| 6c578cfd78 | |||
| a17abec799 | |||
| 2a71b70a34 | |||
| 03f77daf19 | |||
| 270b0c1af6 | |||
| 317bb523a4 | |||
| 2c8ad87b7e | |||
| 5e06729029 | |||
| 21bcfe1157 | |||
| 3aeaaaf4f2 | |||
| 3a9d1395db | |||
| 90c46d99d4 | |||
| 96f44fefd4 | |||
| 38a0a76b69 | |||
| 7fc73b6038 | |||
| 6b61dbc2da | |||
| fd3158fd15 | |||
| ff7135bf2c | |||
| 74bac570c7 | |||
| 5f999035c3 | |||
| fa7b5a3559 | |||
| 187821b2ae | |||
| 1435ba9658 | |||
| 62e2e1703c | |||
| 21a732379b | |||
| 8ac035d146 | |||
| d7e7fb065e | |||
| 11d3b8ab3b | |||
| 566e5996bc | |||
| 51618c7dbd | |||
| bdff3a6135 | |||
| ef7cd4ff5d | |||
| 431e437dee | |||
| cebd43e75a | |||
| 17bfbf95f2 | |||
| dad525be40 | |||
| 7dd0dbd594 | |||
| a0bf423a50 | |||
| 288b060983 | |||
| 5ba60d4fd0 | |||
| 07dae97fe6 | |||
| b210f67728 | |||
| 728d1d58c2 | |||
| 6b9650d451 | |||
| 72ae9072bf | |||
| e82263dc14 | |||
| f03b218775 | |||
| c840b59ae1 | |||
| 1213fc449a | |||
| ca21bb0f0c | |||
| 00555b2df6 | |||
| efca120470 | |||
| a178c3943a | |||
| 01ed1f20ad | |||
| e2bd67083e | |||
| 31fb0a87c9 | |||
| ac4d9fc602 | |||
| 8b1b581dbe | |||
| ebdaa24cfc | |||
| 5633e3adf8 | |||
| fcae5e066d | |||
| c312aea75f | |||
| 1e6e19ecd2 | |||
| 0866b04766 | |||
| 78cef8d58e | |||
| ce84aee8da | |||
| 1ba1665215 | |||
| 60fb18c8e2 | |||
| c042b490b8 | |||
| f544b46d97 | |||
| 70759724fe | |||
| fbfe252df6 | |||
| 2c3def8c7b | |||
| 47e67e8299 | |||
| ec15516230 | |||
| 462013bc2a | |||
| 6b5e53864d | |||
| a8a47589c8 | |||
| b9d567d421 | |||
| 81c77af558 | |||
| 1121680da6 | |||
| d31f2e8894 | |||
| 5895a59cb2 | |||
| 3e5e8d7a42 | |||
| 518a7fd2cf | |||
| 6c832d1754 | |||
| d898b5f23e | |||
| c38a1428f1 | |||
| 759eeccc1f | |||
| d0bc3b203c | |||
| 831b68b6cc | |||
| a06111f445 | |||
| 31fdd30c13 | |||
| e207ef89d5 | |||
| 1261da2e5b | |||
| 0c917bc41e | |||
| f525d6c7e6 | |||
| ed7c67a622 | |||
| 99281df5fb | |||
| 24c2fd6a15 | |||
| ec3fe34dc0 | |||
| 56f36da5f9 | |||
| 9bbd774175 | |||
| 020ac32ee6 | |||
| 67a72210ac | |||
| 020f41fd1e | |||
| 820eb8cc32 | |||
| 47fa5c2009 | |||
| 9b0c929423 | |||
| 93105a45fe | |||
| d8b2f4d367 | |||
| f1478bb2ca | |||
| 8b3c377688 | |||
| 8c98b02dca | |||
| 3743e35e8a | |||
| 05a02de4a9 | |||
| c28378cbb5 | |||
| b2bef63b6b | |||
| 6513e14b21 | |||
| fd53755ad6 | |||
| 1dbacb3027 | |||
| 910d9a7662 | |||
| 09bd8c6b21 | |||
| 908d108858 | |||
| 3135993cf4 | |||
| 7a315b5fd4 | |||
| 4bd6dcc3d7 | |||
| 3f7fa19cdf | |||
| 867ec4d125 | |||
| 164467f3a2 | |||
| fc9a2ddc2a | |||
| 543cb45c11 | |||
| c49e5adc52 | |||
| 0fedd446ca | |||
| 0c7b8a68d9 | |||
| 6dd6accbcc | |||
| ca67f7f79d | |||
| 1aa12c5857 | |||
| 80707fc438 | |||
| ff121dfeb8 | |||
| c3aa6a441b | |||
| 496d32e35b | |||
| 291fa58757 | |||
| eddbc2f986 | |||
| 81b8281d2c | |||
| 57f87d9a4c | |||
| c9d0c57d86 | |||
| 54ab5a9243 | |||
| 17b6b27cd7 | |||
| ed131ca1fd | |||
| 190d65cdee | |||
| dbf2e337f0 | |||
| 12e76bed4f | |||
| e00db80dae | |||
| 5de0aa8145 | |||
| 91ffb25027 | |||
| 6bcbdfedf0 | |||
| 3f42128cb9 | |||
| ccb8f98df5 | |||
| 591a597333 | |||
| 6388f3a5b8 | |||
| 22f52f4af2 | |||
| ceaaff8c9b | |||
| a318495046 | |||
| 8ffc6d3821 | |||
| 2036e46da0 | |||
| b82000e87c | |||
| 144906fd8f | |||
| 8a109e9013 | |||
| ba05f6b470 | |||
| 2f80ae7e84 | |||
| e248fef130 | |||
| 174724ddd3 | |||
| 730945d892 | |||
| 4abdce8c58 | |||
| 55b75dc48d | |||
| f6cea1a683 | |||
| 8d205600b8 | |||
| aa35f60fad | |||
| b627ae1874 | |||
| 46afa6e733 | |||
| c01b189477 | |||
| 966935b677 | |||
| f2f8ca4528 | |||
| 7844bd2f42 | |||
| ac3d51e2cd | |||
| b899b54bb8 | |||
| 7a17de49b2 | |||
| 79180dd918 | |||
| 0d98ada479 | |||
| 5d4fc10ab7 | |||
| e37dfeb080 | |||
| eddae2a9dd | |||
| 6bd7eec615 | |||
| b240e91290 | |||
| 4e0149df29 | |||
| 065872e686 | |||
| 7ab0f5b7c8 | |||
| fd31682242 | |||
| 56c8b62fcf | |||
| c3f879346a | |||
| 6da65ed033 | |||
| 553c6b6c4a | |||
| ac5f74a48f | |||
| e725a7be77 | |||
| 2d22d85c49 | |||
| d960708dac | |||
| c62ad005f5 | |||
| 3edfe8e8bb | |||
| 68fa1bfdae | |||
| 6f9722e05b | |||
| bd6b23400e | |||
| 066d35967e | |||
| b6d2fea847 | |||
| 2b932cff70 | |||
| f356e53f7e | |||
| bb1ff187a3 | |||
| d99a1b1c21 | |||
| c36497e87c | |||
| a32487ad88 | |||
| bd4946db37 | |||
| 69f143dd9d | |||
| 15408bfa1c | |||
| edc715021d | |||
| 392472b027 | |||
| 69741fa47c | |||
| 484720bcda | |||
| f3cc51fb06 | |||
| 452ea7084a | |||
| bba059fc44 | |||
| 3f75cace2b | |||
| 03027813c1 | |||
| 8e9d0c3e9a | |||
| 6c8813c9de | |||
| ec314eb479 | |||
| 77e4457244 | |||
| 0119db094d | |||
| 9c35515d6f | |||
| 1546d7da22 | |||
| 61720f3f2a | |||
| 7749399239 | |||
| d143b82068 | |||
| 606e7c1079 | |||
| a650632c4e | |||
| 3c118f74e4 | |||
| bc3055f6e1 | |||
| 7c86ae0b7e | |||
| 595bfb2711 | |||
| 5f39a3d52f | |||
| e7077781e6 | |||
| 42d15db4ca | |||
| c2599981d6 | |||
| a1647a41ff | |||
| bf2fc7702b | |||
| f814408702 | |||
| 6b1958bfd0 | |||
| bc120ffa76 | |||
| 5ea454a0b0 | |||
| da574f895c | |||
| 1c445e91d9 | |||
| 5d03eb0656 | |||
| becb6845a6 | |||
| be3ee3b216 | |||
| 3747674968 | |||
| ff9d088c5f | |||
| 12db11d559 | |||
| 7e1aca33a5 | |||
| 07a1c68354 | |||
| f4d7c6531f | |||
| e9ca054682 | |||
| 1069bdd0d8 | |||
| ff882a58d7 | |||
| dddc8c3d94 | |||
| 720525b67b | |||
| cc12f63d36 | |||
| 5c67553596 | |||
| 0ccda8db58 | |||
| 556c0e1db2 | |||
| 6d7b89b881 | |||
| 47777b4343 | |||
| 2eb1d2a65d | |||
| ce057c6473 | |||
| 46cfe8b632 | |||
| 2e5eff6e3d | |||
| dd506efeb6 | |||
| 9897d3102e | |||
| 8d92d22fda | |||
| b99764b1ad | |||
| 621582cf11 | |||
| b96233f90b | |||
| 88dfb88bcc | |||
| 75bfe9b3bf | |||
| be9444c76b | |||
| 65e21a421d | |||
| 87b33dda7e | |||
| 2f097c8f6c | |||
| 8cbdea1417 | |||
| 48bdd154f6 | |||
| ae0e157c34 | |||
| 53fcdd9a47 | |||
| 3d6be3bf92 | |||
| 2d7fba3f52 | |||
| e02d8ff2cd | |||
| f8cee25958 | |||
| 99c133aae1 | |||
| cedb32904e | |||
| e73f932083 | |||
| 4645d3ac8b | |||
| 1cdf8b7f23 | |||
| 1e18f53e6a | |||
| fc8cfb05d0 | |||
| fc0c0571fe | |||
| e6ca29e199 | |||
| 7413a8a698 | |||
| 205032e094 | |||
| 9c6f438e22 | |||
| 4f2587554a | |||
| 369fdd84bf | |||
| 5c3b668e92 | |||
| 141db45051 | |||
| 8f9bc8f058 | |||
| be372604fe | |||
| 6c25fc6a8d | |||
| 2eef021587 | |||
| f4fe74f972 | |||
| 9eac6e6e56 | |||
| e5c310f455 | |||
| d8f73dfa56 | |||
| f128d0caf0 | |||
| aa499ceba2 | |||
| 01306afc2d | |||
| 9a3cd0273b | |||
| ac25683f33 | |||
| 624b2112d8 | |||
| 8bd34dc87e | |||
| 948779bcfc | |||
| a74b3a19f7 | |||
| 931d9fbf61 | |||
| a8c76004db | |||
| 0df4596f79 | |||
| cf549df049 | |||
| bd3783154b | |||
| 6919408905 | |||
| f4c08a5981 | |||
| 7fff55da96 | |||
| 3c4dbd1a80 | |||
| f26af38c1e | |||
| 7c6705c75c | |||
| b193bc0b8f | |||
| 1a90887465 | |||
| 82440affac | |||
| 6d2f75c5dc | |||
| 18bc079632 | |||
| 4091a9c499 | |||
| 9346f2d149 | |||
| 8ab52959e8 | |||
| bad95e99c8 | |||
| dbd7fd70be | |||
| 125d070cfe | |||
| 15acf181d1 | |||
| e049f9b868 | |||
| 6a886c5276 | |||
| 1ec190bfe7 | |||
| 7ca032b3f5 | |||
| 13b917d1a0 | |||
| 961072e2ac | |||
| 8a7815268b | |||
| c7e1ffd926 | |||
| 729ab01a5f | |||
| 0a16be4395 | |||
| 47cdb5564a | |||
| f7d5a24d17 | |||
| 8daff4d0a4 | |||
| a38d66fd41 | |||
| 0cab01780d | |||
| 4afc14dee8 | |||
| 00753ffe86 | |||
| 523b1edc44 | |||
| 4966a84614 | |||
| 9247a775fa | |||
| b185b51b31 | |||
| d98960d053 | |||
| d417743654 | |||
| c4bea124fb | |||
| c37410b5de | |||
| b90c94125c | |||
| efbf5d4c5b | |||
| 35532b0c73 | |||
| 4c09b988e4 | |||
| c673581c32 | |||
| bcd718b178 | |||
| 2b9357cb6d | |||
| 26d84041c7 | |||
| 93b4047143 | |||
| a6d488696b | |||
| 3dbd131e49 | |||
| 57cb575483 | |||
| 24ef66be4c | |||
| d07a49f605 | |||
| 4eba28db7a | |||
| b73a3f8912 | |||
| 9f47f2ce85 | |||
| f2aca734a3 | |||
| 09cb637a86 | |||
| 11e7034cec | |||
| f12c18d76b | |||
| 0da39a1b8b | |||
| f29fe5054c | |||
| c8c0164964 | |||
| 52dd657913 | |||
| c30f9fe412 | |||
| bea5dd1d4a | |||
| 8726a0858a | |||
| 74bc747599 | |||
| cbc8fdcb0c | |||
| 3b79b4f1ca | |||
| 5692a76650 | |||
| 7a009ad0af | |||
| e5e75e7092 | |||
| 01b8fd2480 | |||
| ee807a44cc | |||
| c9b905eb18 | |||
| e9c7bf830e | |||
| 8bc97d5bd3 | |||
| f2c241c323 | |||
| 9c512ffe28 | |||
| 53a1da6249 | |||
| d4274e8ca8 | |||
| 49a9f12841 | |||
| d7fa040e3c | |||
| 9baa1e2088 | |||
| 482457205a | |||
| 3b2ec319e2 | |||
| a0f7e75a9a | |||
| c725e53e4c | |||
| 1d7c43a302 | |||
| df7c1c5bb7 | |||
| bb05353b7e | |||
| 7ac92d77e5 | |||
| cf00ecb756 | |||
| 525f2fd0cd | |||
| 3e841cef06 | |||
| a8527df80a | |||
| 51b2ad5c77 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the form below.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||
required: true
|
||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is
|
||||
placeholder: Describe the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe what you expected...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually happened...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: App Version
|
||||
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
|
||||
placeholder: "e.g., v2.2.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Which platform are you using?
|
||||
options:
|
||||
- Android
|
||||
- iOS
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device & OS Version
|
||||
description: What device and OS version are you using?
|
||||
placeholder: "e.g., Samsung Galaxy S24, Android 14"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: download-service
|
||||
attributes:
|
||||
label: Download Service
|
||||
description: Which download service were you using when the bug occurred?
|
||||
options:
|
||||
- Tidal
|
||||
- Qobuz
|
||||
- Amazon Music
|
||||
- Deezer (search only)
|
||||
- Not applicable
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
description: |
|
||||
If applicable, add logs or screenshots to help explain your problem.
|
||||
|
||||
**To get logs:**
|
||||
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||
2. Reproduce the bug
|
||||
3. Go to Settings > Logs
|
||||
4. Tap Share button to export logs
|
||||
placeholder: Paste logs or drag & drop screenshots here...
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem
|
||||
placeholder: Add any other context...
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: README
|
||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||
about: Check the README for setup instructions and FAQ
|
||||
- name: Extension Development Guide
|
||||
url: https://zarz.moe/docs
|
||||
about: Documentation for building SpotiFLAC extensions
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Download Issue
|
||||
description: Report issues with downloading specific tracks or albums
|
||||
title: "[Download]: "
|
||||
labels: ["download-issue"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Having trouble downloading a specific track or album? Please provide details below.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||
required: true
|
||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: issue-type
|
||||
attributes:
|
||||
label: Issue Type
|
||||
description: What kind of download issue are you experiencing?
|
||||
options:
|
||||
- Track not found on service
|
||||
- Wrong track downloaded
|
||||
- Download fails/errors
|
||||
- Metadata incorrect
|
||||
- Audio quality issue
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: spotify-url
|
||||
attributes:
|
||||
label: Spotify URL
|
||||
description: The Spotify URL of the track/album you're trying to download
|
||||
placeholder: "https://open.spotify.com/track/..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: track-info
|
||||
attributes:
|
||||
label: Track Info
|
||||
description: Artist name and track title
|
||||
placeholder: "Artist - Track Title"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: download-service
|
||||
attributes:
|
||||
label: Download Service
|
||||
description: Which service did you try to download from?
|
||||
options:
|
||||
- Tidal
|
||||
- Qobuz
|
||||
- Amazon Music
|
||||
- All services
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: search-service
|
||||
attributes:
|
||||
label: Search Service
|
||||
description: Which search service are you using?
|
||||
options:
|
||||
- Spotify
|
||||
- Deezer
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe the issue in detail
|
||||
placeholder: |
|
||||
What happened? What did you expect?
|
||||
If wrong track was downloaded, what track was downloaded instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: App Version
|
||||
description: Which version of SpotiFLAC are you using?
|
||||
placeholder: "e.g., v2.2.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots / Logs
|
||||
description: |
|
||||
If applicable, add screenshots or logs.
|
||||
|
||||
**To get logs:**
|
||||
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||
2. Try downloading the track again
|
||||
3. Go to Settings > Logs
|
||||
4. Tap Share button to export logs
|
||||
placeholder: Drag & drop screenshots or paste logs here...
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Extension API Feature Request
|
||||
description: Request new API features or capabilities for extension development
|
||||
title: "[Extension API]: "
|
||||
labels: ["enhancement", "extension-api"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping improve the SpotiFLAC Extension API!
|
||||
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||
required: true
|
||||
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extension_goal
|
||||
attributes:
|
||||
label: What are you trying to build?
|
||||
description: Describe the extension or feature you're developing
|
||||
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: current_limitation
|
||||
attributes:
|
||||
label: Current API Limitation
|
||||
description: What's missing or limiting in the current extension API?
|
||||
placeholder: |
|
||||
The current API doesn't support:
|
||||
- [missing feature 1]
|
||||
- [missing feature 2]
|
||||
|
||||
This prevents me from...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed_api
|
||||
attributes:
|
||||
label: Proposed API / Feature
|
||||
description: Describe the API or feature you'd like to see added
|
||||
placeholder: |
|
||||
I would like to have:
|
||||
- A new function `api.newFeature()` that does X
|
||||
- A new manifest field `newOption` that enables Y
|
||||
- Access to Z capability...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use_case
|
||||
attributes:
|
||||
label: Use Case Example
|
||||
description: Provide a code example of how you would use this feature
|
||||
placeholder: |
|
||||
```javascript
|
||||
// Example usage in extension code
|
||||
function download(request, progressCallback) {
|
||||
const result = api.proposedFeature(params);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: api_category
|
||||
attributes:
|
||||
label: API Category
|
||||
description: What category does this feature fall under?
|
||||
options:
|
||||
- HTTP/Network API
|
||||
- File System API
|
||||
- Storage API
|
||||
- FFmpeg/Audio Processing
|
||||
- Manifest Options
|
||||
- Runtime Functions
|
||||
- UI Integration
|
||||
- Authentication
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How critical is this for your extension?
|
||||
options:
|
||||
- Blocker - Cannot build my extension without this
|
||||
- High - Major functionality depends on this
|
||||
- Medium - Would significantly improve my extension
|
||||
- Low - Nice to have
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: workaround
|
||||
attributes:
|
||||
label: Current Workaround
|
||||
description: Are you using any workaround currently? If so, describe it.
|
||||
placeholder: "Currently I'm working around this by..."
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, links to similar APIs, or examples from other platforms
|
||||
placeholder: "Similar feature in other platforms: ..."
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting a feature! Please fill out the form below.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and this feature hasn't been requested yet
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / Motivation
|
||||
description: Is your feature request related to a problem? Please describe.
|
||||
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like
|
||||
placeholder: A clear description of what you want to happen...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or features you've considered
|
||||
placeholder: Other approaches you've thought about...
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
description: What category does this feature fall under?
|
||||
options:
|
||||
- UI/UX Improvement
|
||||
- Download Feature
|
||||
- New Service Integration
|
||||
- Metadata/Tagging
|
||||
- Performance
|
||||
- Settings/Configuration
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or screenshots about the feature request
|
||||
placeholder: Add any other context or screenshots...
|
||||
@@ -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
|
||||
@@ -3,13 +3,13 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v1.0.0)'
|
||||
description: "Version tag (e.g., v1.0.0)"
|
||||
required: true
|
||||
default: 'v1.0.0'
|
||||
default: "v1.0.0"
|
||||
|
||||
jobs:
|
||||
# Get version first (quick job)
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-version
|
||||
|
||||
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
@@ -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'
|
||||
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
|
||||
@@ -85,7 +85,20 @@ jobs:
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
uses: android-actions/setup-android@v3
|
||||
run: |
|
||||
# Use pre-installed Android SDK on GitHub runners
|
||||
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||
|
||||
# Accept licenses
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
|
||||
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
||||
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
# Set NDK path
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
@@ -103,7 +116,7 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
@@ -113,7 +126,14 @@ jobs:
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build APK (Release - unsigned)
|
||||
run: flutter build apk --release --split-per-abi
|
||||
run: |
|
||||
flutter build apk --release --split-per-abi || true
|
||||
# Verify APKs were created
|
||||
ls -la build/app/outputs/flutter-apk/
|
||||
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
||||
echo "ERROR: APK not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign APKs
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
@@ -125,7 +145,7 @@ jobs:
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
@@ -138,28 +158,28 @@ 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
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: "1.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') }}
|
||||
@@ -174,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
|
||||
|
||||
@@ -182,51 +202,51 @@ jobs:
|
||||
run: |
|
||||
ls -la ios/Frameworks/
|
||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||
|
||||
|
||||
- name: Add XCFramework to Xcode project
|
||||
run: |
|
||||
# Install xcodeproj gem for modifying Xcode project
|
||||
sudo gem install xcodeproj
|
||||
|
||||
|
||||
# Create Ruby script to add framework
|
||||
cat > add_framework.rb << 'EOF'
|
||||
require 'xcodeproj'
|
||||
|
||||
|
||||
project_path = 'ios/Runner.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
|
||||
# Get the main target
|
||||
target = project.targets.find { |t| t.name == 'Runner' }
|
||||
|
||||
|
||||
# Get or create Frameworks group
|
||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||
|
||||
|
||||
# Add XCFramework reference
|
||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||
|
||||
|
||||
# Add to frameworks build phase
|
||||
frameworks_build_phase = target.frameworks_build_phase
|
||||
frameworks_build_phase.add_file_reference(framework_ref)
|
||||
|
||||
|
||||
# Add to embed frameworks build phase
|
||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||
if embed_phase
|
||||
build_file = embed_phase.add_file_reference(framework_ref)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||
end
|
||||
|
||||
|
||||
project.save
|
||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||
EOF
|
||||
|
||||
|
||||
ruby add_framework.rb
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
@@ -236,20 +256,46 @@ jobs:
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build iOS (unsigned)
|
||||
run: flutter build ios --release --no-codesign
|
||||
run: |
|
||||
# Build Flutter iOS without codesigning
|
||||
flutter build ios --release --no-codesign --config-only
|
||||
|
||||
# Use xcodebuild with code signing disabled
|
||||
cd ios
|
||||
xcodebuild -workspace Runner.xcworkspace \
|
||||
-scheme Runner \
|
||||
-configuration Release \
|
||||
-sdk iphoneos \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath build/Runner.xcarchive \
|
||||
archive \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
DEVELOPMENT_TEAM=""
|
||||
|
||||
- name: Create IPA
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
mkdir -p build/ios/ipa
|
||||
cd build/ios/iphoneos
|
||||
cd ios/build/Runner.xcarchive/Products/Applications
|
||||
mkdir Payload
|
||||
cp -r Runner.app Payload/
|
||||
zip -r ../ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||
# Use absolute path to avoid relative path issues
|
||||
zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||
rm -rf Payload
|
||||
|
||||
- name: Verify IPA created
|
||||
run: |
|
||||
ls -la build/ios/ipa/
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
if [ ! -f "build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" ]; then
|
||||
echo "ERROR: IPA not created!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||
@@ -259,44 +305,36 @@ jobs:
|
||||
needs: [get-version, build-android, build-ios]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
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"
|
||||
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
|
||||
@@ -304,38 +342,47 @@ jobs:
|
||||
- name: Prepare release body
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
cat > /tmp/release_body.txt << 'HEADER'
|
||||
## SpotiFLAC $VERSION
|
||||
|
||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
||||
|
||||
### What's New
|
||||
HEADER
|
||||
|
||||
# Replace $VERSION in header
|
||||
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
|
||||
|
||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
|
||||
|
||||
# Start with git-cliff changelog, but replace its compare footer with a
|
||||
# deterministic previous-tag lookup from git.
|
||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
|
||||
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
|
||||
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
|
||||
>> /tmp/release_body.txt
|
||||
fi
|
||||
|
||||
# Append download section
|
||||
cat >> /tmp/release_body.txt << FOOTER
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Downloads
|
||||
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
||||
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||
|
||||
#### Android
|
||||
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
||||
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||
|
||||
#### iOS
|
||||
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||
|
||||
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
|
||||
  
|
||||
FOOTER
|
||||
|
||||
|
||||
echo "Release body:"
|
||||
cat /tmp/release_body.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
@@ -345,3 +392,185 @@ jobs:
|
||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-altstore:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, build-ios, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Update apps.json
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
|
||||
if [ -z "$IPA_FILE" ]; then
|
||||
echo "WARNING: IPA file not found, skipping apps.json update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
||||
|
||||
if [ ! -f apps.json ]; then
|
||||
echo "WARNING: apps.json not found on main, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq --arg ver "$VERSION_NUM" \
|
||||
--arg date "$DATE" \
|
||||
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||
--argjson size "$IPA_SIZE" \
|
||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||
|
||||
echo "Updated apps.json:"
|
||||
cat apps.json
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add apps.json
|
||||
git diff --cached --quiet && echo "No changes to commit" || \
|
||||
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
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 '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
||||
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
|
||||
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
|
||||
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||
sed 's/&/\&/g' | \
|
||||
sed 's/</\</g' | \
|
||||
sed 's/>/\>/g' | \
|
||||
sed 's/\*\*\([^*]*\)\*\*/<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!"
|
||||
|
||||
@@ -6,15 +6,20 @@ Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.cursorignore
|
||||
.cursorrules
|
||||
|
||||
# Kiro specs (development only)
|
||||
.kiro/
|
||||
|
||||
# Design assets (banners, mockups)
|
||||
design/
|
||||
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
# Development notes
|
||||
COMPARISON_PC_vs_ANDROID.md
|
||||
# Documentation (development only, published separately)
|
||||
docs/
|
||||
|
||||
# Old spotiflac_android folder (moved to root)
|
||||
spotiflac_android/
|
||||
@@ -38,7 +43,7 @@ go_backend/*.xcframework/
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/
|
||||
android/app/libs/gobackend.aar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
@@ -52,3 +57,26 @@ ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
nul
|
||||
network_requests.txt
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
hs_err_*.log
|
||||
flutter_*.log
|
||||
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
@@ -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,282 @@
|
||||
# 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. **Use FVM (Flutter Version: 3.38.1)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
4. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
6. **Set up Go environment (Go Version: 1.25.7)**
|
||||
```bash
|
||||
cd go_backend
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile init
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
cd ..
|
||||
```
|
||||
|
||||
7. **Run the app**
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
### 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,19 +1,29 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
<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>
|
||||
|
||||
Get Spotify tracks in true 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>
|
||||
|
||||
> **Active Development Notice**: This app is under heavy development. New builds may be pushed multiple times daily. If frequent update notifications are annoying, tap "Don't remind" when the update dialog appears, or disable update checks in Settings.
|
||||
<div align="center">
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
</div>
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -24,22 +34,141 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
## Other project
|
||||
---
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
|
||||
### Installing Extensions
|
||||
|
||||
1. Open the **Store** tab in the app
|
||||
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||
3. Browse and install extensions with one tap
|
||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
5. Configure extension settings if needed
|
||||
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
|
||||
> [!NOTE]
|
||||
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
|
||||
|
||||
---
|
||||
|
||||
## Related Projects
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||
|
||||
## Disclaimer
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||
|
||||
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||
---
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
## FAQ
|
||||
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||
<details>
|
||||
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||
<br>
|
||||
|
||||
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.
|
||||
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
||||
|
||||
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.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||
<br>
|
||||
|
||||
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||
<br>
|
||||
|
||||
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||
- **Tidal** up to 24-bit/192kHz
|
||||
- **Qobuz** up to 24-bit/192kHz
|
||||
- **Deezer** up to 16-bit/44.1kHz
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I download playlists?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||
<br>
|
||||
|
||||
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Is this app safe?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||
<br>
|
||||
|
||||
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||
```
|
||||
|
||||
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC is useful to you, consider supporting development:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||
</a>
|
||||
|
||||
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||
|
||||
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
|
||||
---
|
||||
|
||||
## API Credits
|
||||
|
||||
| | | | | |
|
||||
|---|---|---|---|---|
|
||||
| [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 the repo** to get notified about all new releases directly from GitHub.
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- .dart_tool/**
|
||||
- lib/**/*.g.dart
|
||||
- lib/l10n/*.dart
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
plugins:
|
||||
- custom_lint
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -23,6 +36,13 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
avoid_dynamic_calls: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
|
||||
custom_lint:
|
||||
rules:
|
||||
- avoid_public_notifier_properties
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -7,9 +10,9 @@ plugins {
|
||||
|
||||
// Load keystore properties for local builds
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = java.util.Properties()
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -32,10 +35,10 @@ android {
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +46,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
@@ -93,8 +96,13 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
implementation(files("libs/gobackend.aar"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
|
||||
// Include all AAR and JAR files from libs folder
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||
}
|
||||
|
||||
@@ -5,29 +5,112 @@
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
-keep class io.flutter.embedding.** { *; }
|
||||
|
||||
# Go backend (gobackend.aar)
|
||||
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||
-dontwarn com.google.android.play.core.splitcompat.**
|
||||
-dontwarn com.google.android.play.core.splitinstall.**
|
||||
-dontwarn com.google.android.play.core.tasks.**
|
||||
|
||||
# Ignore missing javax.xml.stream (not used on Android)
|
||||
-dontwarn javax.xml.stream.**
|
||||
|
||||
# Go backend (gobackend.aar) - CRITICAL for release builds
|
||||
-keep class gobackend.** { *; }
|
||||
-keep class go.** { *; }
|
||||
-keep interface gobackend.** { *; }
|
||||
-keepclassmembers class gobackend.** { *; }
|
||||
|
||||
# Go mobile binding internals
|
||||
-keep class org.golang.** { *; }
|
||||
-dontwarn org.golang.**
|
||||
|
||||
# FFmpeg Kit
|
||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||
-keep class com.arthenica.smartexception.** { *; }
|
||||
# FFmpeg Kit (new fork package)
|
||||
-keep class com.antonkarpenko.ffmpegkit.** { *; }
|
||||
-keep class com.antonkarpenko.smartexception.** { *; }
|
||||
|
||||
# Apache Tika (if used by FFmpeg)
|
||||
-dontwarn org.apache.tika.**
|
||||
|
||||
# Keep native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Kotlin coroutines
|
||||
# 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">
|
||||
|
||||
@@ -15,6 +15,9 @@ import androidx.core.app.NotificationCompat
|
||||
/**
|
||||
* Foreground service to keep downloads running when app is in background.
|
||||
* This prevents Android from killing the download process or throttling network.
|
||||
*
|
||||
* Note: Android 15+ (API 35+) has a 6-hour timeout for dataSync foreground services.
|
||||
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
|
||||
*/
|
||||
class DownloadService : Service() {
|
||||
|
||||
@@ -101,11 +104,22 @@ class DownloadService : Service() {
|
||||
updateNotification(progress, total)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
/**
|
||||
* Called when the foreground service timeout is reached (Android 15+, API 35+).
|
||||
* dataSync services have a 6-hour limit in a 24-hour period.
|
||||
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||
*/
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
||||
|
||||
stopForegroundService()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
@@ -123,14 +137,13 @@ class DownloadService : Service() {
|
||||
|
||||
private fun startForegroundService() {
|
||||
isRunning = true
|
||||
|
||||
// Acquire wake lock to prevent CPU sleep
|
||||
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
WAKELOCK_TAG
|
||||
).apply {
|
||||
acquire(60 * 60 * 1000L) // 1 hour max
|
||||
acquire(60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val notification = buildNotification(0, 0)
|
||||
|
||||
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -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")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "SpotiFLAC Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "3.9.0",
|
||||
"versionDate": "2026-03-25",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 34477323
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,103 @@
|
||||
# git-cliff configuration for SpotiFLAC Mobile
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# Template for the changelog body
|
||||
body = """
|
||||
{%- macro remote_url() -%}
|
||||
https://github.com/zarzet/SpotiFLAC-Mobile
|
||||
{%- endmacro -%}
|
||||
|
||||
{% if version %}\
|
||||
## {{ version | trim_start_matches(pat="v") }}
|
||||
{% else %}\
|
||||
## Unreleased
|
||||
{% endif %}\
|
||||
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
||||
{{ commit.message | upper_first }}\
|
||||
{% if commit.github.pr_number %} \
|
||||
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
||||
{% endif %}\
|
||||
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||
{%- endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
|
||||
|
||||
### New Contributors
|
||||
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
|
||||
* @{{ contributor.username }} made their first contribution
|
||||
{%- if contributor.pr_number %} in \
|
||||
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif -%}
|
||||
|
||||
{% if version %}
|
||||
{% if previous.version %}
|
||||
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
|
||||
{% endif %}
|
||||
{% else -%}
|
||||
{% raw %}\n{% endraw %}
|
||||
{% endif %}
|
||||
"""
|
||||
# Remove leading and trailing whitespace
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
# Parse conventional commits
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
|
||||
# Process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
|
||||
# Regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
# Strip conventional commit prefix for cleaner messages
|
||||
# (group header already shows the type)
|
||||
]
|
||||
|
||||
# Regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
# Skip noise: translation commits from Crowdin
|
||||
{ message = "^New translations", skip = true },
|
||||
{ message = "^Update source file", skip = true },
|
||||
# Skip merge commits
|
||||
{ message = "^Merge", skip = true },
|
||||
# Skip version bump commits
|
||||
{ message = "^v\\d+", skip = true },
|
||||
{ message = "^chore: update VirusTotal", skip = true },
|
||||
|
||||
# Group by conventional commit type
|
||||
{ message = "^feat", group = "<!-- 0 -->New Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
|
||||
{ message = "^doc", group = "<!-- 4 -->Documentation" },
|
||||
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
|
||||
{ message = "^chore\\(l10n\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
|
||||
]
|
||||
|
||||
# Protect breaking changes from being skipped
|
||||
protect_breaking_commits = true
|
||||
|
||||
# Filter out commits by matching patterns
|
||||
filter_commits = false
|
||||
|
||||
# Tag pattern for version detection
|
||||
tag_pattern = "v[0-9].*"
|
||||
|
||||
# Sort commits by newest first
|
||||
sort_commits = "newest"
|
||||
|
||||
[remote.github]
|
||||
owner = "zarzet"
|
||||
repo = "SpotiFLAC-Mobile"
|
||||
@@ -0,0 +1,19 @@
|
||||
files:
|
||||
- source: /lib/l10n/arb/app_en.arb
|
||||
translation: /lib/l10n/arb/app_%locale%.arb
|
||||
languages_mapping:
|
||||
locale:
|
||||
# Short codes for single-variant languages
|
||||
de: de
|
||||
es: es
|
||||
fr: fr
|
||||
hi: hi
|
||||
id: id
|
||||
ja: ja
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt: pt
|
||||
ru: ru
|
||||
# Full codes for Chinese variants
|
||||
zh-CN: zh_CN
|
||||
zh-TW: zh_TW
|
||||
@@ -1,403 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
return &AmazonDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||
regions: []string{"us", "eu"}, // Same regions as PC
|
||||
}
|
||||
}
|
||||
|
||||
// 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, outputDir string) (string, string, string, error) {
|
||||
var lastError error
|
||||
|
||||
for _, region := range a.regions {
|
||||
fmt.Printf("[Amazon] Trying region: %s...\n", region)
|
||||
|
||||
// Build base URL for DoubleDouble service
|
||||
// Decode base64 service URL (same as PC)
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
// Step 1: Submit download request
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
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...")
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
var submitResp DoubleDoubleSubmitResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !submitResp.Success || submitResp.ID == "" {
|
||||
lastError = fmt.Errorf("submit request failed")
|
||||
continue
|
||||
}
|
||||
|
||||
downloadID := submitResp.ID
|
||||
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
|
||||
|
||||
// Step 2: Poll for completion
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
fmt.Println("[Amazon] Waiting for download to complete...")
|
||||
|
||||
maxWait := 300 * time.Second // 5 minutes max wait
|
||||
elapsed := time.Duration(0)
|
||||
pollInterval := 3 * time.Second
|
||||
|
||||
for elapsed < maxWait {
|
||||
time.Sleep(pollInterval)
|
||||
elapsed += pollInterval
|
||||
|
||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
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!")
|
||||
|
||||
// Build download URL
|
||||
fileURL := status.URL
|
||||
if strings.HasPrefix(fileURL, "./") {
|
||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||
} else if strings.HasPrefix(fileURL, "/") {
|
||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
||||
}
|
||||
|
||||
trackName := status.Current.Name
|
||||
artist := status.Current.Artist
|
||||
|
||||
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||
return fileURL, trackName, artist, nil
|
||||
|
||||
} else if status.Status == "error" {
|
||||
errorMsg := status.FriendlyStatus
|
||||
if errorMsg == "" {
|
||||
errorMsg = "Unknown error"
|
||||
}
|
||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
||||
break
|
||||
} else {
|
||||
// Still processing
|
||||
friendlyStatus := status.FriendlyStatus
|
||||
if friendlyStatus == "" {
|
||||
friendlyStatus = status.Status
|
||||
}
|
||||
fmt.Printf("\r[Amazon] %s...", friendlyStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed >= maxWait {
|
||||
lastError = fmt.Errorf("download timeout")
|
||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
||||
continue
|
||||
}
|
||||
|
||||
if lastError != nil {
|
||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("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 {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use item progress writer
|
||||
var bytesWritten int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(out, itemID)
|
||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
// Fallback: direct copy without progress tracking
|
||||
bytesWritten, err = io.Copy(out, resp.Body)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
// AmazonDownloadResult contains download result with quality info
|
||||
type AmazonDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// downloadFromAmazon downloads a track using the request parameters
|
||||
// Uses DoubleDouble service (same as PC version)
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
// Get Amazon URL from SongLink
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
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)")
|
||||
}
|
||||
|
||||
// Create output directory if needed
|
||||
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)
|
||||
}
|
||||
|
||||
// Build filename using Spotify metadata (more accurate)
|
||||
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)
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
|
||||
// Download file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Log track info from DoubleDouble (for debugging)
|
||||
if trackName != "" && artistName != "" {
|
||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||
}
|
||||
|
||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
}
|
||||
|
||||
// Download cover to memory (avoids file permission issues on Android)
|
||||
var coverData []byte
|
||||
if req.CoverURL != "" {
|
||||
fmt.Println("[Amazon] Downloading cover to memory...")
|
||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
||||
if err == nil {
|
||||
coverData = data
|
||||
fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Embed lyrics if enabled
|
||||
if req.EmbedLyrics {
|
||||
fmt.Println("[Amazon] Fetching lyrics...")
|
||||
lyricsClient := NewLyricsClient()
|
||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
if lyricsErr != nil {
|
||||
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
|
||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
fmt.Println("[Amazon] No lyrics found for this track")
|
||||
} else {
|
||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||
|
||||
// Read actual quality from the downloaded FLAC file
|
||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
// Return 0 to indicate unknown quality
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: 0,
|
||||
SampleRate: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: quality.BitDepth,
|
||||
SampleRate: quality.SampleRate,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const explicitKey = "content://media/external/audio/media/42|123456"
|
||||
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
|
||||
if got != explicitKey {
|
||||
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp failed: %v", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
tempFile.Close()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
got := resolveLibraryCoverCacheKey(tempPath, "")
|
||||
if !strings.HasPrefix(got, tempPath+"|") {
|
||||
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return exec.Command(ffmpegPath, args...)
|
||||
}
|
||||
return exec.Command("ffmpeg", args...)
|
||||
}
|
||||
|
||||
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
cmd := ffmpegCommand(args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not available")
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"flac",
|
||||
sourceFlac,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=32x32:d=1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
coverPath,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
sourceFlac,
|
||||
"-b:a",
|
||||
"320k",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
baseMp3,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
baseMp3,
|
||||
"-i",
|
||||
coverPath,
|
||||
"-map",
|
||||
"0:a",
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
"-map",
|
||||
"1:0",
|
||||
"-c:v:0",
|
||||
"copy",
|
||||
"-id3v2_version",
|
||||
"3",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
"-metadata:s:v",
|
||||
"title=Album cover",
|
||||
"-metadata:s:v",
|
||||
"comment=Cover (front)",
|
||||
"-c:a",
|
||||
"copy",
|
||||
finalMp3,
|
||||
)
|
||||
|
||||
meta, err := ReadID3Tags(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||
}
|
||||
|
||||
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||
}
|
||||
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||
}
|
||||
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(finalMp3); err != nil {
|
||||
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
}
|
||||
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
if itemID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func cancelDownload(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
if ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
cancelMap[itemID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
func isDownloadCancelled(itemID string) bool {
|
||||
if itemID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
canceled := ok && entry.canceled
|
||||
cancelMu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
func clearDownloadCancel(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
delete(cancelMap, itemID)
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
@@ -4,36 +4,54 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Spotify image size codes (same as PC version)
|
||||
const (
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||
spotifySize300 = "ab67616d00001e02"
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
|
||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
||||
// This avoids file permission issues on Android
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
if coverURL == "" {
|
||||
return nil, fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
|
||||
GoLog("[Cover] Original URL: %s", coverURL)
|
||||
|
||||
downloadURL := convertSmallToMedium(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||
}
|
||||
|
||||
// Upgrade to max quality if requested
|
||||
downloadURL := coverURL
|
||||
if maxQuality {
|
||||
downloadURL = upgradeToMaxQuality(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
|
||||
// Create request with User-Agent (required by Spotify CDN)
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -54,48 +72,86 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
|
||||
sizeKB := len(data) / 1024
|
||||
var resolution string
|
||||
if sizeKB > 200 {
|
||||
resolution = "~2000x2000 (hi-res)"
|
||||
} else if sizeKB > 50 {
|
||||
resolution = "~640x640"
|
||||
} else {
|
||||
resolution = "~300x300"
|
||||
}
|
||||
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
||||
// Uses same logic as PC version - replaces 640x640 size code with max resolution
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify image URLs can be upgraded by changing the size parameter
|
||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
||||
// ab67616d0000b273 = 640x640
|
||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
||||
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
// Try max resolution first
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
// Verify max resolution URL is available
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
req, err := http.NewRequest("HEAD", maxURL, nil)
|
||||
if err == nil {
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return maxURL
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return upgradeDeezerCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return upgradeTidalCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return upgradeQobuzCover(coverURL)
|
||||
}
|
||||
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
||||
func upgradeDeezerCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeTidalCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeQobuzCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
return upgradeToMaxQuality(imageURL)
|
||||
result = upgradeToMaxQuality(result)
|
||||
}
|
||||
|
||||
return imageURL
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CueSheet represents a parsed .cue file
|
||||
type CueSheet struct {
|
||||
Performer string `json:"performer"`
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Tracks []CueTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||
line = strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(line)
|
||||
|
||||
if strings.HasPrefix(upper, "REM ") {
|
||||
matches := reRemCommand.FindStringSubmatch(line)
|
||||
if len(matches) == 3 {
|
||||
key := strings.ToUpper(matches[1])
|
||||
value := unquoteCue(matches[2])
|
||||
switch key {
|
||||
case "GENRE":
|
||||
sheet.Genre = value
|
||||
case "DATE":
|
||||
sheet.Date = value
|
||||
case "COMMENT":
|
||||
sheet.Comment = value
|
||||
case "COMPOSER":
|
||||
if currentTrack != nil {
|
||||
currentTrack.Composer = value
|
||||
} else {
|
||||
sheet.Composer = value
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||
value := unquoteCue(line[len("PERFORMER "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Performer = value
|
||||
} else {
|
||||
sheet.Performer = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "TITLE ") {
|
||||
value := unquoteCue(line[len("TITLE "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Title = value
|
||||
} else {
|
||||
sheet.Title = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "FILE ") {
|
||||
rest := line[len("FILE "):]
|
||||
fname, ftype := parseCueFileLine(rest)
|
||||
sheet.FileName = fname
|
||||
sheet.FileType = ftype
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "TRACK ") {
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
parts := strings.Fields(line)
|
||||
trackNum := 0
|
||||
if len(parts) >= 2 {
|
||||
trackNum, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
|
||||
currentTrack = &CueTrack{
|
||||
Number: trackNum,
|
||||
PreGap: -1,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 {
|
||||
indexNum, _ := strconv.Atoi(parts[1])
|
||||
timeSec := parseCueTimestamp(parts[2])
|
||||
switch indexNum {
|
||||
case 0:
|
||||
currentTrack.PreGap = timeSec
|
||||
case 1:
|
||||
currentTrack.StartTime = timeSec
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||
value := unquoteCue(line[len("SONGWRITER "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Composer = value
|
||||
} else {
|
||||
sheet.Composer = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading cue file: %w", err)
|
||||
}
|
||||
|
||||
if len(sheet.Tracks) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found in cue file")
|
||||
}
|
||||
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
||||
}
|
||||
|
||||
// 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 ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||
cuePath,
|
||||
audioDir,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
"",
|
||||
scanTime,
|
||||
)
|
||||
}
|
||||
|
||||
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCueSheetForLibrary(
|
||||
cuePath,
|
||||
sheet,
|
||||
audioPath,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
coverCacheKey,
|
||||
scanTime,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
||||
if sheet == nil {
|
||||
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
resolveBase := cuePath
|
||||
if audioDir != "" {
|
||||
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||
if audioPath == "" {
|
||||
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||
}
|
||||
return audioPath, nil
|
||||
}
|
||||
|
||||
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
|
||||
// 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 := SaveCoverToCacheWithHintAndKey(
|
||||
audioPath,
|
||||
"",
|
||||
coverCacheDir,
|
||||
coverCacheKey,
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
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,608 @@
|
||||
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 != "" {
|
||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
|
||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||
}
|
||||
return trackURL, nil
|
||||
}
|
||||
|
||||
// Try SongLink
|
||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
|
||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
// Fall through to ISRC search instead of using wrong track.
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try 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 {
|
||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
|
||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return nil // Can't verify — don't block the download.
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: trackResp.Track.Name,
|
||||
ArtistName: trackResp.Track.Artists,
|
||||
ISRC: trackResp.Track.ISRC,
|
||||
Duration: trackResp.Track.DurationMS / 1000,
|
||||
SkipNameVerification: skipNameVerification,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||
if err != nil {
|
||||
if deezerURLErr != nil {
|
||||
return DeezerDownloadResult{}, fmt.Errorf(
|
||||
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
|
||||
deezerURLErr,
|
||||
err,
|
||||
)
|
||||
}
|
||||
return DeezerDownloadResult{}, err
|
||||
}
|
||||
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,
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
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
|
||||
}
|
||||
@@ -1,59 +1,171 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
||||
type ISRCIndex struct {
|
||||
index map[string]string // ISRC (uppercase) -> file path
|
||||
outputDir string
|
||||
buildTime time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
isrcIndexCache = make(map[string]*ISRCIndex)
|
||||
isrcIndexCacheMu sync.RWMutex
|
||||
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
|
||||
isrcIndexTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
// Fast path: check cache first
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists := isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||
mu := buildLock.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Double-check cache after acquiring lock (another goroutine may have built it)
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists = isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||
return idx
|
||||
}
|
||||
|
||||
return buildISRCIndex(outputDir)
|
||||
}
|
||||
|
||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
idx := &ISRCIndex{
|
||||
index: make(map[string]string),
|
||||
outputDir: outputDir,
|
||||
buildTime: time.Now(),
|
||||
}
|
||||
|
||||
if outputDir == "" {
|
||||
return idx
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
fileCount := 0
|
||||
|
||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".flac" {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata, err := ReadMetadata(path)
|
||||
if err != nil || metadata.ISRC == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||
fileCount++
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||
|
||||
isrcIndexCacheMu.Lock()
|
||||
isrcIndexCache[outputDir] = idx
|
||||
isrcIndexCacheMu.Unlock()
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||
if isrc == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
|
||||
path, exists := idx.index[strings.ToUpper(isrc)]
|
||||
return path, exists
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) remove(isrc string) {
|
||||
if isrc == "" {
|
||||
return
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
delete(idx.index, strings.ToUpper(isrc))
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||
path, _ := idx.lookup(isrc)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||
if isrc == "" || filePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
idx.index[strings.ToUpper(isrc)] = filePath
|
||||
}
|
||||
|
||||
func InvalidateISRCCache(outputDir string) {
|
||||
isrcIndexCacheMu.Lock()
|
||||
delete(isrcIndexCache, outputDir)
|
||||
isrcIndexCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
if isrc == "" || outputDir == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Walk through directory looking for FLAC files
|
||||
var foundFile string
|
||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only check FLAC files
|
||||
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".flac") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read metadata from file
|
||||
metadata, err := ReadMetadata(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if ISRC matches
|
||||
if metadata.ISRC == isrc {
|
||||
foundFile = path
|
||||
return filepath.SkipAll // Stop walking
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if foundFile != "" {
|
||||
return foundFile, true
|
||||
idx := GetISRCIndex(outputDir)
|
||||
filePath, exists := idx.lookup(isrc)
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return "", false
|
||||
if !CheckFileExists(filePath) {
|
||||
// Stale index entry; remove it and return not found.
|
||||
idx.remove(isrc)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return filePath, true
|
||||
}
|
||||
|
||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||
// Returns the filepath if exists, empty string if not
|
||||
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||
return filepath, nil
|
||||
}
|
||||
|
||||
// CheckFileExists checks if a file with the given name exists
|
||||
func CheckFileExists(filePath string) bool {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
@@ -61,3 +173,86 @@ func CheckFileExists(filePath string) bool {
|
||||
}
|
||||
return !info.IsDir() && info.Size() > 0
|
||||
}
|
||||
|
||||
type FileExistenceResult struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Exists bool `json:"exists"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
|
||||
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||
return "", fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||
}
|
||||
|
||||
results := make([]FileExistenceResult, len(tracks))
|
||||
|
||||
isrcIdx := GetISRCIndex(outputDir)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, track := range tracks {
|
||||
wg.Add(1)
|
||||
go func(resultIdx int, t struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
}) {
|
||||
defer wg.Done()
|
||||
|
||||
result := FileExistenceResult{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
Exists: false,
|
||||
}
|
||||
|
||||
if t.ISRC != "" {
|
||||
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
|
||||
result.Exists = true
|
||||
result.FilePath = filePath
|
||||
}
|
||||
}
|
||||
|
||||
results[resultIdx] = result
|
||||
}(i, track)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
resultJSON, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal results: %w", err)
|
||||
}
|
||||
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
|
||||
func PreBuildISRCIndex(outputDir string) error {
|
||||
if outputDir == "" {
|
||||
return fmt.Errorf("output directory is required")
|
||||
}
|
||||
|
||||
buildISRCIndex(outputDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||
if outputDir == "" || isrc == "" || filePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists := isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists {
|
||||
idx.Add(isrc, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(nil)
|
||||
|
||||
if result["genre"] != "" {
|
||||
t.Fatalf("expected empty genre, got %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "" {
|
||||
t.Fatalf("expected empty label, got %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "" {
|
||||
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
||||
Genre: "Rock",
|
||||
Label: "EMI",
|
||||
Copyright: "(C) Queen",
|
||||
})
|
||||
|
||||
if result["genre"] != "Rock" {
|
||||
t.Fatalf("unexpected genre: %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "EMI" {
|
||||
t.Fatalf("unexpected label: %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "(C) Queen" {
|
||||
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
||||
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
||||
SpotifyID: "deezer:3135556",
|
||||
Name: "Love Of My Life",
|
||||
Artists: "Queen",
|
||||
AlbumName: "A Night at the Opera",
|
||||
ISRC: "GBUM71029604",
|
||||
ReleaseDate: "1975-11-21",
|
||||
})
|
||||
|
||||
if result["spotify_id"] != "deezer:3135556" {
|
||||
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
||||
}
|
||||
if result["id"] != "3135556" {
|
||||
t.Fatalf("unexpected id: %v", result["id"])
|
||||
}
|
||||
if result["track_id"] != "3135556" {
|
||||
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
||||
}
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected success=true, got %v", result["success"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Bonus Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album (Deluxe)",
|
||||
AlbumArtist: "Artist",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 14,
|
||||
DiscNumber: 1,
|
||||
ISRC: "REQ123",
|
||||
CoverURL: "https://example.com/cover.jpg",
|
||||
Genre: "Pop",
|
||||
Label: "Label",
|
||||
Copyright: "Copyright",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Bonus Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
ReleaseDate: "2023-12-01",
|
||||
TrackNumber: 2,
|
||||
DiscNumber: 9,
|
||||
ISRC: "RES456",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"tidal",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Album != req.AlbumName {
|
||||
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
|
||||
}
|
||||
if resp.ReleaseDate != req.ReleaseDate {
|
||||
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
|
||||
}
|
||||
if resp.TrackNumber != req.TrackNumber {
|
||||
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
|
||||
}
|
||||
if resp.DiscNumber != req.DiscNumber {
|
||||
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
|
||||
}
|
||||
if resp.Artist != result.Artist {
|
||||
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
|
||||
}
|
||||
if resp.ISRC != result.ISRC {
|
||||
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
DownloadRequest{
|
||||
AlbumName: "Album (Deluxe Edition)",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 13,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
"Album",
|
||||
"2023-01-01",
|
||||
3,
|
||||
1,
|
||||
)
|
||||
|
||||
if album != "Album (Deluxe Edition)" {
|
||||
t.Fatalf("album = %q", album)
|
||||
}
|
||||
if releaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q", releaseDate)
|
||||
}
|
||||
if trackNumber != 13 {
|
||||
t.Fatalf("track number = %d", trackNumber)
|
||||
}
|
||||
if discNumber != 2 {
|
||||
t.Fatalf("disc number = %d", discNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
AlbumArtist: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"qobuz",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.CoverURL != result.CoverURL {
|
||||
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
SpotifyID: "spotify-track-id",
|
||||
AlbumName: "Original Album",
|
||||
ReleaseDate: "2024-01-01",
|
||||
ISRC: "REQ123",
|
||||
}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
AlbumName: "Resolved Album",
|
||||
ReleaseDate: "",
|
||||
ISRC: "",
|
||||
})
|
||||
|
||||
if req.ReleaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||
}
|
||||
if req.AlbumName != "Resolved Album" {
|
||||
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||
}
|
||||
if req.ISRC != "REQ123" {
|
||||
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
ReleaseDate: "",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "first",
|
||||
Name: "Song Title",
|
||||
Artists: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "",
|
||||
ProviderID: "spotify",
|
||||
},
|
||||
{
|
||||
ID: "second",
|
||||
Name: "Song Title",
|
||||
Artists: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "2024-03-09",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected a selected track")
|
||||
}
|
||||
if best.ID != "second" {
|
||||
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
AlbumArtist: "",
|
||||
ReleaseDate: "",
|
||||
TrackNumber: 0,
|
||||
DiscNumber: 0,
|
||||
ISRC: "",
|
||||
Genre: "",
|
||||
Label: "",
|
||||
Copyright: "",
|
||||
}
|
||||
|
||||
metadata := buildReEnrichFFmpegMetadata(req, "")
|
||||
|
||||
if metadata["TITLE"] != "Song" {
|
||||
t.Fatalf("title = %q", metadata["TITLE"])
|
||||
}
|
||||
if metadata["ARTIST"] != "Artist" {
|
||||
t.Fatalf("artist = %q", metadata["ARTIST"])
|
||||
}
|
||||
if metadata["ALBUM"] != "Album" {
|
||||
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
"ALBUMARTIST",
|
||||
"DATE",
|
||||
"TRACKNUMBER",
|
||||
"DISCNUMBER",
|
||||
"ISRC",
|
||||
"GENRE",
|
||||
"ORGANIZATION",
|
||||
"COPYRIGHT",
|
||||
"LYRICS",
|
||||
"UNSYNCEDLYRICS",
|
||||
} {
|
||||
if _, exists := metadata[key]; exists {
|
||||
t.Fatalf("did not expect key %s in metadata: %#v", key, metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ExtensionType string
|
||||
|
||||
const (
|
||||
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
|
||||
)
|
||||
|
||||
type SettingType string
|
||||
|
||||
const (
|
||||
SettingTypeString SettingType = "string"
|
||||
SettingTypeNumber SettingType = "number"
|
||||
SettingTypeBool SettingType = "boolean"
|
||||
SettingTypeSelect SettingType = "select"
|
||||
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||
)
|
||||
|
||||
type ExtensionPermissions struct {
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
}
|
||||
|
||||
type ExtensionSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
type QualityOption struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
type QualitySpecificSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type SearchFilter struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type SearchBehaviorConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
|
||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
|
||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
|
||||
Filters []SearchFilter `json:"filters,omitempty"`
|
||||
}
|
||||
|
||||
type URLHandlerConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Patterns []string `json:"patterns,omitempty"`
|
||||
}
|
||||
|
||||
type TrackMatchingConfig struct {
|
||||
CustomMatching bool `json:"customMatching"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
DurationTolerance int `json:"durationTolerance,omitempty"`
|
||||
}
|
||||
|
||||
type PostProcessingHook struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
|
||||
SupportedFormats []string `json:"supportedFormats,omitempty"`
|
||||
}
|
||||
|
||||
type PostProcessingConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ManifestValidationError) Error() string {
|
||||
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||
var manifest ExtensionManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) Validate() error {
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Version) == "" {
|
||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Author) == "" {
|
||||
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Description) == "" {
|
||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||
}
|
||||
|
||||
if len(m.Types) == 0 {
|
||||
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
||||
}
|
||||
|
||||
for _, t := range m.Types {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
|
||||
return &ManifestValidationError{
|
||||
Field: "type",
|
||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, setting := range m.Settings {
|
||||
if strings.TrimSpace(setting.Key) == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].key", i),
|
||||
Message: "setting key is required",
|
||||
}
|
||||
}
|
||||
|
||||
if setting.Type == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].type", i),
|
||||
Message: "setting type is required",
|
||||
}
|
||||
}
|
||||
|
||||
// Select type requires options
|
||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].options", i),
|
||||
Message: "select type requires options",
|
||||
}
|
||||
}
|
||||
|
||||
if setting.Type == SettingTypeButton && setting.Action == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].action", i),
|
||||
Message: "button type requires action (JS function name)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||
for _, et := range m.Types {
|
||||
if et == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||
return m.HasType(ExtensionTypeMetadataProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||
return m.HasType(ExtensionTypeDownloadProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) 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 {
|
||||
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||
if allowed == domain {
|
||||
return true
|
||||
}
|
||||
// Support wildcard subdomains (e.g., *.example.com)
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
suffix := allowed[1:]
|
||||
if strings.HasSuffix(domain, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
if !m.HasURLHandler() {
|
||||
return false
|
||||
}
|
||||
|
||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||
for _, pattern := range m.URLHandler.Patterns {
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
if strings.Contains(urlStr, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||
if m.PostProcessing == nil {
|
||||
return nil
|
||||
}
|
||||
return m.PostProcessing.Hooks
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
original := GetMetadataProviderPriority()
|
||||
defer SetMetadataProviderPriority(original)
|
||||
|
||||
SetMetadataProviderPriority([]string{"tidal"})
|
||||
got := GetMetadataProviderPriority()
|
||||
want := []string{"tidal", "deezer", "qobuz"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
originalPriority := GetMetadataProviderPriority()
|
||||
originalSearch := searchBuiltInMetadataTracksFunc
|
||||
defer func() {
|
||||
SetMetadataProviderPriority(originalPriority)
|
||||
searchBuiltInMetadataTracksFunc = originalSearch
|
||||
}()
|
||||
|
||||
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
||||
|
||||
var calls []string
|
||||
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
calls = append(calls, providerID)
|
||||
switch providerID {
|
||||
case "qobuz":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
|
||||
}, nil
|
||||
case "tidal":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
||||
}, nil
|
||||
case "deezer":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||
}
|
||||
if len(tracks) != 3 {
|
||||
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
|
||||
}
|
||||
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
|
||||
t.Fatalf("unexpected track provider order: %+v", tracks)
|
||||
}
|
||||
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
||||
t.Fatalf("unexpected provider call order: %v", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
const DefaultJSTimeout = 30 * time.Second
|
||||
|
||||
var (
|
||||
extensionAuthState = make(map[string]*ExtensionAuthState)
|
||||
extensionAuthStateMu sync.RWMutex
|
||||
)
|
||||
|
||||
type ExtensionAuthState struct {
|
||||
PendingAuthURL string
|
||||
AuthCode string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
IsAuthenticated bool
|
||||
PKCEVerifier string
|
||||
PKCEChallenge string
|
||||
}
|
||||
|
||||
type PendingAuthRequest struct {
|
||||
ExtensionID string
|
||||
AuthURL string
|
||||
CallbackURL string
|
||||
}
|
||||
|
||||
var (
|
||||
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
||||
pendingAuthRequestsMu sync.RWMutex
|
||||
)
|
||||
|
||||
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||
pendingAuthRequestsMu.RLock()
|
||||
defer pendingAuthRequestsMu.RUnlock()
|
||||
return pendingAuthRequests[extensionID]
|
||||
}
|
||||
|
||||
func ClearPendingAuthRequest(extensionID string) {
|
||||
pendingAuthRequestsMu.Lock()
|
||||
defer pendingAuthRequestsMu.Unlock()
|
||||
delete(pendingAuthRequests, extensionID)
|
||||
}
|
||||
|
||||
func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[extensionID] = state
|
||||
}
|
||||
state.AuthCode = authCode
|
||||
}
|
||||
|
||||
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[extensionID] = state
|
||||
}
|
||||
state.AccessToken = accessToken
|
||||
state.RefreshToken = refreshToken
|
||||
state.ExpiresAt = expiresAt
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
downloadClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
|
||||
activeDownloadMu sync.RWMutex
|
||||
activeDownloadItemID string
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = ""
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
||||
r.activeDownloadMu.RLock()
|
||||
defer r.activeDownloadMu.RUnlock()
|
||||
return r.activeDownloadItemID
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: timeout,
|
||||
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
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
type RedirectBlockedError struct {
|
||||
Domain string
|
||||
IsPrivate bool
|
||||
}
|
||||
|
||||
func (e *RedirectBlockedError) Error() string {
|
||||
if e.IsPrivate {
|
||||
return "redirect blocked: private/local network access denied"
|
||||
}
|
||||
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
|
||||
}
|
||||
|
||||
func isPrivateIP(host string) bool {
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
if hostLower == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(hostLower); ip != nil {
|
||||
return isPrivateIPAddr(ip)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type simpleCookieJar struct {
|
||||
cookies map[string][]*http.Cookie
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newSimpleCookieJar() (*simpleCookieJar, error) {
|
||||
return &simpleCookieJar{
|
||||
cookies: make(map[string][]*http.Cookie),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
key := u.Host
|
||||
j.cookies[key] = append(j.cookies[key], cookies...)
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
httpObj := vm.NewObject()
|
||||
httpObj.Set("get", r.httpGet)
|
||||
httpObj.Set("post", r.httpPost)
|
||||
httpObj.Set("put", r.httpPut)
|
||||
httpObj.Set("delete", r.httpDelete)
|
||||
httpObj.Set("patch", r.httpPatch)
|
||||
httpObj.Set("request", r.httpRequest)
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
storageObj := vm.NewObject()
|
||||
storageObj.Set("get", r.storageGet)
|
||||
storageObj.Set("set", r.storageSet)
|
||||
storageObj.Set("remove", r.storageRemove)
|
||||
vm.Set("storage", storageObj)
|
||||
|
||||
credentialsObj := vm.NewObject()
|
||||
credentialsObj.Set("store", r.credentialsStore)
|
||||
credentialsObj.Set("get", r.credentialsGet)
|
||||
credentialsObj.Set("remove", r.credentialsRemove)
|
||||
credentialsObj.Set("has", r.credentialsHas)
|
||||
vm.Set("credentials", credentialsObj)
|
||||
|
||||
authObj := vm.NewObject()
|
||||
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||
authObj.Set("getAuthCode", r.authGetCode)
|
||||
authObj.Set("setAuthCode", r.authSetCode)
|
||||
authObj.Set("clearAuth", r.authClear)
|
||||
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||
authObj.Set("getTokens", r.authGetTokens)
|
||||
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||
authObj.Set("getPKCE", r.authGetPKCE)
|
||||
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
fileObj.Set("delete", r.fileDelete)
|
||||
fileObj.Set("read", r.fileRead)
|
||||
fileObj.Set("write", r.fileWrite)
|
||||
fileObj.Set("copy", r.fileCopy)
|
||||
fileObj.Set("move", r.fileMove)
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
vm.Set("file", fileObj)
|
||||
|
||||
ffmpegObj := vm.NewObject()
|
||||
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||
vm.Set("ffmpeg", ffmpegObj)
|
||||
|
||||
matchingObj := vm.NewObject()
|
||||
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
||||
vm.Set("matching", matchingObj)
|
||||
|
||||
utilsObj := vm.NewObject()
|
||||
utilsObj.Set("base64Encode", r.base64Encode)
|
||||
utilsObj.Set("base64Decode", r.base64Decode)
|
||||
utilsObj.Set("md5", r.md5Hash)
|
||||
utilsObj.Set("sha256", r.sha256Hash)
|
||||
utilsObj.Set("hmacSHA256", r.hmacSHA256)
|
||||
utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64)
|
||||
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
||||
utilsObj.Set("parseJSON", r.parseJSON)
|
||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
logObj := vm.NewObject()
|
||||
logObj.Set("debug", r.logDebug)
|
||||
logObj.Set("info", r.logInfo)
|
||||
logObj.Set("warn", r.logWarn)
|
||||
logObj.Set("error", r.logError)
|
||||
vm.Set("log", logObj)
|
||||
|
||||
gobackendObj := vm.NewObject()
|
||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
|
||||
vm.Set("fetch", r.fetchPolyfill)
|
||||
|
||||
vm.Set("atob", r.atobPolyfill)
|
||||
vm.Set("btoa", r.btoaPolyfill)
|
||||
|
||||
r.registerTextEncoderDecoder(vm)
|
||||
|
||||
r.registerURLClass(vm)
|
||||
|
||||
r.registerJSONGlobal(vm)
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func validateExtensionAuthURL(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid auth URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("invalid auth URL: only https is allowed")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("invalid auth URL: hostname is required")
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
if isPrivateIP(host) {
|
||||
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarizeURLForLog(urlStr string) string {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return urlStr
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return parsed.Scheme + "://"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "auth URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
authURL := call.Arguments[0].String()
|
||||
callbackURL := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PendingAuthURL = authURL
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Auth URL will be opened by the app",
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists || state.AuthCode == "" {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(state.AuthCode)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
arg := call.Arguments[0].Export()
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
state.AuthCode = v
|
||||
case map[string]interface{}:
|
||||
if code, ok := v["code"].(string); ok {
|
||||
state.AuthCode = code
|
||||
}
|
||||
if accessToken, ok := v["access_token"].(string); ok {
|
||||
state.AccessToken = accessToken
|
||||
state.IsAuthenticated = true
|
||||
}
|
||||
if refreshToken, ok := v["refresh_token"].(string); ok {
|
||||
state.RefreshToken = refreshToken
|
||||
}
|
||||
if expiresIn, ok := v["expires_in"].(float64); ok {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
delete(pendingAuthRequests, r.extensionID)
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth state cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"access_token": state.AccessToken,
|
||||
"refresh_token": state.RefreshToken,
|
||||
"is_authenticated": state.IsAuthenticated,
|
||||
}
|
||||
|
||||
if !state.ExpiresAt.IsZero() {
|
||||
result["expires_at"] = state.ExpiresAt.Unix()
|
||||
result["is_expired"] = time.Now().After(state.ExpiresAt)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// Length should be between 43-128 characters (RFC 7636)
|
||||
func generatePKCEVerifier(length int) (string, error) {
|
||||
if length < 43 {
|
||||
length = 43
|
||||
}
|
||||
if length > 128 {
|
||||
length = 128
|
||||
}
|
||||
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
if len(verifier) > length {
|
||||
verifier = verifier[:length]
|
||||
}
|
||||
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
length := 64
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||
length = int(l)
|
||||
}
|
||||
}
|
||||
|
||||
verifier, err := generatePKCEVerifier(length)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists || state.PKCEVerifier == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": state.PKCEVerifier,
|
||||
"challenge": state.PKCEChallenge,
|
||||
"method": "S256",
|
||||
})
|
||||
}
|
||||
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
authURL, _ := config["authUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
|
||||
if authURL == "" || clientID == "" || redirectURI == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "authUrl, clientId, and redirectUri are required",
|
||||
})
|
||||
}
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
|
||||
verifier, err := generatePKCEVerifier(64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
|
||||
})
|
||||
}
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("invalid authUrl: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
query.Set("client_id", clientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("response_type", "code")
|
||||
query.Set("code_challenge", challenge)
|
||||
query.Set("code_challenge_method", "S256")
|
||||
|
||||
if scope != "" {
|
||||
query.Set("scope", scope)
|
||||
}
|
||||
|
||||
for k, v := range extraParams {
|
||||
query.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
fullAuthURL := parsedURL.String()
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: fullAuthURL,
|
||||
CallbackURL: redirectURI,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"authUrl": fullAuthURL,
|
||||
"pkce": map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
tokenURL, _ := config["tokenUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
code, _ := config["code"].(string)
|
||||
|
||||
if tokenURL == "" || clientID == "" || code == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "tokenUrl, clientId, and code are required",
|
||||
})
|
||||
}
|
||||
|
||||
extensionAuthStateMu.RLock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
var verifier string
|
||||
if exists {
|
||||
verifier = state.PKCEVerifier
|
||||
}
|
||||
extensionAuthStateMu.RUnlock()
|
||||
|
||||
if verifier == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
|
||||
})
|
||||
}
|
||||
|
||||
if err := r.validateDomain(tokenURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", clientID)
|
||||
formData.Set("code", code)
|
||||
formData.Set("code_verifier", verifier)
|
||||
if redirectURI != "" {
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
}
|
||||
|
||||
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||
for k, v := range extraParams {
|
||||
formData.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||
if len(bodyPreview) > 1000 {
|
||||
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||
}
|
||||
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||
errDesc, _ := tokenResp["error_description"].(string)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": errMsg,
|
||||
"error_description": errDesc,
|
||||
})
|
||||
}
|
||||
|
||||
accessToken, _ := tokenResp["access_token"].(string)
|
||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
|
||||
if accessToken == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no access_token in response",
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists = extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.AccessToken = accessToken
|
||||
state.RefreshToken = refreshToken
|
||||
state.IsAuthenticated = true
|
||||
if expiresIn > 0 {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
state.PKCEVerifier = ""
|
||||
state.PKCEChallenge = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": tokenResp["token_type"],
|
||||
}
|
||||
if expiresIn > 0 {
|
||||
result["expires_in"] = expiresIn
|
||||
}
|
||||
if scope, ok := tokenResp["scope"].(string); ok {
|
||||
result["scope"] = scope
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute.
|
||||
type FFmpegCommand struct {
|
||||
ExtensionID string
|
||||
Command string
|
||||
InputPath string
|
||||
OutputPath string
|
||||
Completed bool
|
||||
Success bool
|
||||
Error string
|
||||
Output string
|
||||
}
|
||||
|
||||
var (
|
||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||
ffmpegCommandsMu sync.RWMutex
|
||||
ffmpegCommandID int64
|
||||
)
|
||||
|
||||
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||
ffmpegCommandsMu.RLock()
|
||||
defer ffmpegCommandsMu.RUnlock()
|
||||
return ffmpegCommands[commandID]
|
||||
}
|
||||
|
||||
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
if cmd, exists := ffmpegCommands[commandID]; exists {
|
||||
cmd.Completed = true
|
||||
cmd.Success = success
|
||||
cmd.Output = output
|
||||
cmd.Error = errorMsg
|
||||
}
|
||||
}
|
||||
|
||||
func ClearFFmpegCommand(commandID string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "command is required",
|
||||
})
|
||||
}
|
||||
|
||||
command := call.Arguments[0].String()
|
||||
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommandID++
|
||||
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||
ffmpegCommands[cmdID] = &FFmpegCommand{
|
||||
ExtensionID: r.extensionID,
|
||||
Command: command,
|
||||
Completed: false,
|
||||
}
|
||||
ffmpegCommandsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||
|
||||
timeout := 5 * time.Minute
|
||||
start := time.Now()
|
||||
for {
|
||||
ffmpegCommandsMu.RLock()
|
||||
cmd := ffmpegCommands[cmdID]
|
||||
completed := cmd != nil && cmd.Completed
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
if completed {
|
||||
ffmpegCommandsMu.RLock()
|
||||
result := map[string]interface{}{
|
||||
"success": cmd.Success,
|
||||
"output": cmd.Output,
|
||||
}
|
||||
if cmd.Error != "" {
|
||||
result["error"] = cmd.Error
|
||||
}
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
if time.Since(start) > timeout {
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "FFmpeg command timed out",
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"bit_depth": quality.BitDepth,
|
||||
"sample_rate": quality.SampleRate,
|
||||
"total_samples": quality.TotalSamples,
|
||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "input and output paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
inputPath := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
options := map[string]interface{}{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||
options = opts
|
||||
}
|
||||
}
|
||||
|
||||
var cmdParts []string
|
||||
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||
|
||||
if codec, ok := options["codec"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-c:a", codec)
|
||||
}
|
||||
|
||||
if bitrate, ok := options["bitrate"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||
}
|
||||
|
||||
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||
}
|
||||
|
||||
if channels, ok := options["channels"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||
}
|
||||
|
||||
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||
|
||||
command := strings.Join(cmdParts, " ")
|
||||
|
||||
execCall := goja.FunctionCall{
|
||||
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||
}
|
||||
return r.ffmpegExecute(execCall)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedDownloadDirs []string
|
||||
allowedDownloadDirsMu sync.RWMutex
|
||||
)
|
||||
|
||||
func SetAllowedDownloadDirs(dirs []string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
allowedDownloadDirs = dirs
|
||||
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
||||
}
|
||||
|
||||
func AddAllowedDownloadDir(dir string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err == nil {
|
||||
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
|
||||
}
|
||||
}
|
||||
|
||||
func isPathInAllowedDirs(absPath string) bool {
|
||||
allowedDownloadDirsMu.RLock()
|
||||
defer allowedDownloadDirsMu.RUnlock()
|
||||
|
||||
for _, allowedDir := range allowedDownloadDirs {
|
||||
if isPathWithinBase(allowedDir, absPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPathWithinBase(baseDir, targetPath string) bool {
|
||||
baseAbs, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(baseAbs, targetAbs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.Clean(rel)
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
|
||||
prefix := ".." + string(filepath.Separator)
|
||||
if rel == ".." || strings.HasPrefix(rel, prefix) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
if !r.manifest.Permissions.File {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
absPath, err := filepath.Abs(cleanPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
if isPathInAllowedDirs(absPath) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||
if !isPathWithinBase(absDataDir, absPath) {
|
||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||
}
|
||||
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "URL and output path are required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullPath, err := r.validatePath(outputPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
headers = make(map[string]string)
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
if progressVal, ok := opts["onProgress"]; ok {
|
||||
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||
onProgress = callable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||
})
|
||||
}
|
||||
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||
})
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" && contentLength > 0 {
|
||||
SetItemBytesTotal(activeItemID, contentLength)
|
||||
}
|
||||
|
||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||
if activeItemID != "" {
|
||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||
}
|
||||
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := progressWriter.Write(buf[0:nr])
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if ew == nil {
|
||||
ew = fmt.Errorf("invalid write result")
|
||||
}
|
||||
}
|
||||
written += int64(nw)
|
||||
if ew != nil {
|
||||
if ew == ErrDownloadCancelled {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "download cancelled",
|
||||
})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||
})
|
||||
}
|
||||
if nr != nw {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "short write",
|
||||
})
|
||||
}
|
||||
|
||||
if onProgress != nil && contentLength > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er != io.EOF {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read response: %v", er),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"size": written,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
_, err = os.Stat(fullPath)
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": string(data),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path and data are required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
data := call.Arguments[1].String()
|
||||
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "source and destination paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
srcPath := call.Arguments[0].String()
|
||||
dstPath := call.Arguments[1].String()
|
||||
|
||||
fullSrc, err := r.validatePath(srcPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullDst, err := r.validatePath(dstPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
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 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),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullDst,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "source and destination paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
srcPath := call.Arguments[0].String()
|
||||
dstPath := call.Arguments[1].String()
|
||||
|
||||
fullSrc, err := r.validatePath(srcPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullDst, err := r.validatePath(dstPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.Rename(fullSrc, fullDst); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to move file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullDst,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"size": info.Size(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type HTTPResponse struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme == "" {
|
||||
return fmt.Errorf("invalid URL: scheme is required")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
if domain == "" {
|
||||
return fmt.Errorf("invalid URL: hostname is required")
|
||||
}
|
||||
|
||||
if isPrivateIP(domain) {
|
||||
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||
}
|
||||
|
||||
if !r.manifest.IsDomainAllowed(domain) {
|
||||
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var bodyStr string
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if method == "DELETE" {
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
jar.mu.Unlock()
|
||||
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
}
|
||||
|
||||
str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
||||
str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String()))
|
||||
|
||||
if str1 == str2 {
|
||||
return r.vm.ToValue(1.0)
|
||||
}
|
||||
|
||||
similarity := calculateStringSimilarity(str1, str2)
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
dur1 := int(call.Arguments[0].ToInteger())
|
||||
dur2 := int(call.Arguments[1].ToInteger())
|
||||
|
||||
tolerance := 3000
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||
tolerance = int(call.Arguments[2].ToInteger())
|
||||
}
|
||||
|
||||
diff := dur1 - dur2
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
str := call.Arguments[0].String()
|
||||
normalized := normalizeStringForMatching(str)
|
||||
return r.vm.ToValue(normalized)
|
||||
}
|
||||
|
||||
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if len(s1) == 0 && len(s2) == 0 {
|
||||
return 1.0
|
||||
}
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
maxLen := len(s1)
|
||||
if len(s2) > maxLen {
|
||||
maxLen = len(s2)
|
||||
}
|
||||
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := range matrix[0] {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 1
|
||||
if s1[i-1] == s2[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1,
|
||||
matrix[i][j-1]+1,
|
||||
matrix[i-1][j-1]+cost,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
func normalizeStringForMatching(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
|
||||
suffixes := []string{
|
||||
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||
" (explicit)", " (clean)", " [explicit]", " [clean]",
|
||||
" (album version)", " (single version)", " (radio edit)",
|
||||
" (feat.", " (ft.", " feat.", " ft.",
|
||||
}
|
||||
for _, suffix := range suffixes {
|
||||
if idx := strings.Index(s, suffix); idx != -1 {
|
||||
s = s[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
s = strings.Join(strings.Fields(result.String()), " ")
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// These polyfills make porting browser/Node.js libraries easier
|
||||
// without compromising sandbox security.
|
||||
|
||||
// Returns a Promise-like object with json(), text() methods.
|
||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.createFetchError("URL is required")
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Body - support string, object (auto-stringify), or nil
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
if h, ok := opts["headers"]; ok && h != nil {
|
||||
switch hv := h.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, v := range hv {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
responseObj := r.vm.NewObject()
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||
responseObj.Set("headers", respHeaders)
|
||||
responseObj.Set("url", urlStr)
|
||||
|
||||
bodyString := string(body)
|
||||
|
||||
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(bodyString)
|
||||
})
|
||||
|
||||
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
return r.vm.ToValue(result)
|
||||
})
|
||||
|
||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||
byteArray := make([]interface{}, len(body))
|
||||
for i, b := range body {
|
||||
byteArray[i] = int(b)
|
||||
}
|
||||
return r.vm.ToValue(byteArray)
|
||||
})
|
||||
|
||||
return responseObj
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
errorObj := r.vm.NewObject()
|
||||
errorObj.Set("ok", false)
|
||||
errorObj.Set("status", 0)
|
||||
errorObj.Set("statusText", "Network Error")
|
||||
errorObj.Set("error", message)
|
||||
errorObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue("")
|
||||
})
|
||||
errorObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
return goja.Undefined()
|
||||
})
|
||||
return errorObj
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
}
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
encoder.Set("encoding", "utf-8")
|
||||
|
||||
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]byte{})
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
bytes := []byte(input)
|
||||
|
||||
result := make([]interface{}, len(bytes))
|
||||
for i, b := range bytes {
|
||||
result[i] = int(b)
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
// Simplified implementation
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"read": len(input),
|
||||
"written": len([]byte(input)),
|
||||
})
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
decoder := call.This
|
||||
|
||||
encoding := "utf-8"
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
encoding = call.Arguments[0].String()
|
||||
}
|
||||
decoder.Set("encoding", encoding)
|
||||
decoder.Set("fatal", false)
|
||||
decoder.Set("ignoreBOM", false)
|
||||
|
||||
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
input := call.Arguments[0].Export()
|
||||
var bytes []byte
|
||||
|
||||
switch v := input.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case []interface{}:
|
||||
bytes = make([]byte, len(v))
|
||||
for i, val := range v {
|
||||
switch n := val.(type) {
|
||||
case int64:
|
||||
bytes[i] = byte(n)
|
||||
case float64:
|
||||
bytes[i] = byte(n)
|
||||
case int:
|
||||
bytes[i] = byte(n)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
return vm.ToValue(v)
|
||||
default:
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
return vm.ToValue(string(bytes))
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||
urlObj := call.This
|
||||
|
||||
if len(call.Arguments) < 1 {
|
||||
urlObj.Set("href", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
baseStr := call.Arguments[1].String()
|
||||
baseURL, err := url.Parse(baseStr)
|
||||
if err == nil {
|
||||
relURL, err := url.Parse(urlStr)
|
||||
if err == nil {
|
||||
urlStr = baseURL.ResolveReference(relURL).String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
urlObj.Set("href", urlStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
urlObj.Set("href", parsed.String())
|
||||
urlObj.Set("protocol", parsed.Scheme+":")
|
||||
urlObj.Set("host", parsed.Host)
|
||||
urlObj.Set("hostname", parsed.Hostname())
|
||||
urlObj.Set("port", parsed.Port())
|
||||
urlObj.Set("pathname", parsed.Path)
|
||||
urlObj.Set("search", "")
|
||||
if parsed.RawQuery != "" {
|
||||
urlObj.Set("search", "?"+parsed.RawQuery)
|
||||
}
|
||||
urlObj.Set("hash", "")
|
||||
if parsed.Fragment != "" {
|
||||
urlObj.Set("hash", "#"+parsed.Fragment)
|
||||
}
|
||||
urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host)
|
||||
urlObj.Set("username", parsed.User.Username())
|
||||
password, _ := parsed.User.Password()
|
||||
urlObj.Set("password", password)
|
||||
|
||||
queryValues := parsed.Query()
|
||||
|
||||
searchParams := vm.NewObject()
|
||||
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
if val := queryValues.Get(key); val != "" {
|
||||
return vm.ToValue(val)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]string{})
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
return vm.ToValue(queryValues[key])
|
||||
})
|
||||
|
||||
searchParams.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
return vm.ToValue(queryValues.Has(key))
|
||||
})
|
||||
|
||||
searchParams.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(queryValues.Encode())
|
||||
})
|
||||
|
||||
urlObj.Set("searchParams", searchParams)
|
||||
|
||||
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||
paramsObj := call.This
|
||||
values := url.Values{}
|
||||
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
init := call.Arguments[0].Export()
|
||||
switch v := init.(type) {
|
||||
case string:
|
||||
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||
values = parsed
|
||||
case map[string]interface{}:
|
||||
for k, val := range v {
|
||||
values.Set(k, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paramsObj.Set("append", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 2 {
|
||||
values.Add(call.Arguments[0].String(), call.Arguments[1].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 1 {
|
||||
values.Del(call.Arguments[0].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
}
|
||||
if val := values.Get(call.Arguments[0].String()); val != "" {
|
||||
return vm.ToValue(val)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]string{})
|
||||
}
|
||||
return vm.ToValue(values[call.Arguments[0].String()])
|
||||
})
|
||||
|
||||
paramsObj.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(values.Has(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
paramsObj.Set("set", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 2 {
|
||||
values.Set(call.Arguments[0].String(), call.Arguments[1].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(values.Encode())
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// JSON is already built-in to Goja; this ensures a fallback exists.
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
parse: function(text) {
|
||||
return utils.parseJSON(text);
|
||||
},
|
||||
stringify: function(value, replacer, space) {
|
||||
return utils.stringifyJSON(value);
|
||||
}
|
||||
};
|
||||
}
|
||||
`
|
||||
_, _ = vm.RunString(jsonScript)
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultStorageFlushDelay = 400 * time.Millisecond
|
||||
storageFlushRetryDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
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) {
|
||||
r.storageCache = make(map[string]interface{})
|
||||
r.storageLoaded = true
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
r.storageMu.RLock()
|
||||
defer r.storageMu.RUnlock()
|
||||
return cloneInterfaceMap(r.storageCache), nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
r.storageMu.RLock()
|
||||
value, exists := r.storageCache[key]
|
||||
r.storageMu.RUnlock()
|
||||
if !exists {
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getSaltPath() string {
|
||||
return filepath.Join(r.dataDir, ".cred_salt")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
if err == nil && len(salt) == 32 {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
salt = make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||
}
|
||||
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
salt, err := r.getOrCreateSalt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
combined := append([]byte(r.extensionID), salt...)
|
||||
hash := sha256.Sum256(combined)
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) 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) {
|
||||
r.credentialsCache = make(map[string]interface{})
|
||||
r.credentialsLoaded = true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
decrypted, err := decryptAES(data, key)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
r.credentialsMu.RLock()
|
||||
defer r.credentialsMu.RUnlock()
|
||||
return cloneInterfaceMap(r.credentialsCache), nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
encrypted, err := encryptAES(data, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
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
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "key and value are required",
|
||||
})
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
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,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
r.credentialsMu.RLock()
|
||||
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||
r.credentialsMu.RUnlock()
|
||||
nextCreds[key] = value
|
||||
|
||||
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,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
r.credentialsMu.RLock()
|
||||
value, exists := r.credentialsCache[key]
|
||||
r.credentialsMu.RUnlock()
|
||||
if !exists {
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
r.credentialsMu.RLock()
|
||||
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||
r.credentialsMu.RUnlock()
|
||||
delete(nextCreds, key)
|
||||
|
||||
if err := r.saveCredentials(nextCreds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
r.credentialsMu.RLock()
|
||||
_, exists := r.credentialsCache[key]
|
||||
r.credentialsMu.RUnlock()
|
||||
return r.vm.ToValue(exists)
|
||||
}
|
||||
|
||||
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
@@ -0,0 +1,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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
hash := md5.Sum([]byte(input))
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
message := call.Arguments[0].String()
|
||||
key := call.Arguments[1].String()
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(message))
|
||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
message := call.Arguments[0].String()
|
||||
key := call.Arguments[1].String()
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(message))
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
var keyBytes []byte
|
||||
keyArg := call.Arguments[0].Export()
|
||||
switch k := keyArg.(type) {
|
||||
case string:
|
||||
keyBytes = []byte(k)
|
||||
case []interface{}:
|
||||
keyBytes = make([]byte, len(k))
|
||||
for i, v := range k {
|
||||
if num, ok := v.(int64); ok {
|
||||
keyBytes[i] = byte(num)
|
||||
} else if num, ok := v.(float64); ok {
|
||||
keyBytes[i] = byte(int(num))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
var msgBytes []byte
|
||||
msgArg := call.Arguments[1].Export()
|
||||
switch m := msgArg.(type) {
|
||||
case string:
|
||||
msgBytes = []byte(m)
|
||||
case []interface{}:
|
||||
msgBytes = make([]byte, len(m))
|
||||
for i, v := range m {
|
||||
if num, ok := v.(int64); ok {
|
||||
msgBytes[i] = byte(num)
|
||||
} else if num, ok := v.(float64); ok {
|
||||
msgBytes[i] = byte(int(num))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
mac := hmac.New(sha1.New, keyBytes)
|
||||
mac.Write(msgBytes)
|
||||
result := mac.Sum(nil)
|
||||
|
||||
jsArray := make([]interface{}, len(result))
|
||||
for i, b := range result {
|
||||
jsArray[i] = int(b)
|
||||
}
|
||||
return r.vm.ToValue(jsArray)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
|
||||
var result interface{}
|
||||
if err := json.Unmarshal([]byte(input), &result); err != nil {
|
||||
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].Export()
|
||||
|
||||
data, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
return r.vm.ToValue(string(data))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "plaintext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
plaintext := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": base64.StdEncoding.EncodeToString(encrypted),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "ciphertext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
ciphertextB64 := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": string(decrypted),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
length := 32
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||
length = int(l)
|
||||
}
|
||||
}
|
||||
|
||||
key := make([]byte, length)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"key": base64.StdEncoding.EncodeToString(key),
|
||||
"hex": hex.EncodeToString(key),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(getRandomUserAgent())
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
parts := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(sanitizeFilename(input))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.Get("gobackend")
|
||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||
gobackendObj = vm.NewObject()
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
}
|
||||
|
||||
obj := gobackendObj.(*goja.Object)
|
||||
|
||||
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"bitDepth": quality.BitDepth,
|
||||
"sampleRate": quality.SampleRate,
|
||||
"totalSamples": quality.TotalSamples,
|
||||
})
|
||||
})
|
||||
|
||||
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
template := call.Arguments[0].String()
|
||||
metadataObj := call.Arguments[1].Export()
|
||||
|
||||
metadata, ok := metadataObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||
})
|
||||
|
||||
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
|
||||
now := time.Now()
|
||||
_, offsetSeconds := now.Zone()
|
||||
offsetMinutes := offsetSeconds / 60
|
||||
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"year": now.Year(),
|
||||
"month": int(now.Month()),
|
||||
"day": now.Day(),
|
||||
"hour": now.Hour(),
|
||||
"minute": now.Minute(),
|
||||
"second": now.Second(),
|
||||
"weekday": int(now.Weekday()),
|
||||
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
|
||||
"timezone": now.Location().String(),
|
||||
"timestamp": now.Unix(),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ExtensionSettingsStore struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
settings map[string]map[string]interface{} // extensionID -> settings
|
||||
}
|
||||
|
||||
var (
|
||||
globalSettingsStore *ExtensionSettingsStore
|
||||
globalSettingsStoreOnce sync.Once
|
||||
)
|
||||
|
||||
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||
globalSettingsStoreOnce.Do(func() {
|
||||
globalSettingsStore = &ExtensionSettingsStore{
|
||||
settings: make(map[string]map[string]interface{}),
|
||||
}
|
||||
})
|
||||
return globalSettingsStore
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.dataDir = dataDir
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
return s.loadAllSettings()
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
||||
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||
entries, err := os.ReadDir(s.dataDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
extensionID := entry.Name()
|
||||
settings, err := s.loadSettings(extensionID)
|
||||
if err != nil {
|
||||
GoLog("[ExtensionSettings] Failed to load settings for %s: %v\n", extensionID, err)
|
||||
continue
|
||||
}
|
||||
s.settings[extensionID] = settings
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
|
||||
dir := filepath.Dir(settingsPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(settingsPath, data, 0644)
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("extension '%s' settings not found", extensionID)
|
||||
}
|
||||
|
||||
value, exists := extSettings[key]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("setting '%s' not found for extension '%s'", key, extensionID)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range extSettings {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.settings[extensionID]; !exists {
|
||||
s.settings[extensionID] = make(map[string]interface{})
|
||||
}
|
||||
|
||||
s.settings[extensionID][key] = value
|
||||
|
||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.settings[extensionID] = settings
|
||||
|
||||
return s.saveSettings(extensionID, settings)
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(extSettings, key)
|
||||
|
||||
return s.saveSettings(extensionID, extSettings)
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.settings, extensionID)
|
||||
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := json.Marshal(s.settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CategoryMetadata = "metadata"
|
||||
CategoryDownload = "download"
|
||||
CategoryUtility = "utility"
|
||||
CategoryLyrics = "lyrics"
|
||||
CategoryIntegration = "integration"
|
||||
)
|
||||
|
||||
type storeExtension struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||
}
|
||||
|
||||
func (e *storeExtension) getDisplayName() string {
|
||||
if e.DisplayName != "" {
|
||||
return e.DisplayName
|
||||
}
|
||||
if e.DisplayNameAlt != "" {
|
||||
return e.DisplayNameAlt
|
||||
}
|
||||
return e.Name
|
||||
}
|
||||
|
||||
func (e *storeExtension) getDownloadURL() string {
|
||||
if e.DownloadURL != "" {
|
||||
return e.DownloadURL
|
||||
}
|
||||
return e.DownloadURLAlt
|
||||
}
|
||||
|
||||
func (e *storeExtension) getIconURL() string {
|
||||
if e.IconURL != "" {
|
||||
return e.IconURL
|
||||
}
|
||||
return e.IconURLAlt
|
||||
}
|
||||
|
||||
func (e *storeExtension) getMinAppVersion() string {
|
||||
if e.MinAppVersion != "" {
|
||||
return e.MinAppVersion
|
||||
}
|
||||
return e.MinAppVersionAlt
|
||||
}
|
||||
|
||||
type storeRegistry struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Extensions []storeExtension `json:"extensions"`
|
||||
}
|
||||
|
||||
type storeExtensionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
IsInstalled bool `json:"is_installed"`
|
||||
InstalledVersion string `json:"installed_version,omitempty"`
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||
resp := storeExtensionResponse{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
Version: e.Version,
|
||||
Author: e.Author,
|
||||
Description: e.Description,
|
||||
DownloadURL: e.getDownloadURL(),
|
||||
IconURL: e.getIconURL(),
|
||||
Category: e.Category,
|
||||
Downloads: e.Downloads,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
MinAppVersion: e.getMinAppVersion(),
|
||||
}
|
||||
|
||||
if len(e.Tags) > 0 {
|
||||
resp.Tags = append([]string(nil), e.Tags...)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
type extensionStore struct {
|
||||
registryURL string
|
||||
cacheDir string
|
||||
cache *storeRegistry
|
||||
cacheMu sync.RWMutex
|
||||
cacheTime time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
globalExtensionStore *extensionStore
|
||||
extensionStoreMu sync.Mutex
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTL = 30 * time.Minute
|
||||
cacheFileName = "store_cache.json"
|
||||
)
|
||||
|
||||
func initExtensionStore(cacheDir string) *extensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
|
||||
if globalExtensionStore == nil {
|
||||
globalExtensionStore = &extensionStore{
|
||||
registryURL: "", // No default - user must provide a registry URL
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
globalExtensionStore.loadDiskCache()
|
||||
}
|
||||
return globalExtensionStore
|
||||
}
|
||||
|
||||
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
||||
// so the next fetch will use the new URL. Disk cache is also cleared.
|
||||
func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
if s.registryURL == registryURL {
|
||||
return
|
||||
}
|
||||
|
||||
s.registryURL = registryURL
|
||||
s.cache = nil
|
||||
s.cacheTime = time.Time{}
|
||||
|
||||
// Clear disk cache since it's from a different registry
|
||||
if s.cacheDir != "" {
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.Remove(cachePath)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||
}
|
||||
|
||||
// GetRegistryURL returns the currently configured registry URL.
|
||||
func (s *extensionStore) getRegistryURL() string {
|
||||
s.cacheMu.RLock()
|
||||
defer s.cacheMu.RUnlock()
|
||||
return s.registryURL
|
||||
}
|
||||
|
||||
func getExtensionStore() *extensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
return globalExtensionStore
|
||||
}
|
||||
|
||||
func (s *extensionStore) loadDiskCache() {
|
||||
if s.cacheDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var cacheData struct {
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &cacheData); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.cache = &cacheData.Registry
|
||||
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
|
||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
}
|
||||
|
||||
func (s *extensionStore) saveDiskCache() {
|
||||
if s.cacheDir == "" || s.cache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cacheData := struct {
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}{
|
||||
Registry: *s.cache,
|
||||
CacheTime: s.cacheTime.Unix(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cacheData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.WriteFile(cachePath, data, 0644)
|
||||
}
|
||||
|
||||
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
if s.registryURL == "" {
|
||||
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||
}
|
||||
|
||||
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 := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
resp, err := client.Get(s.registryURL)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch registry: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||
}
|
||||
|
||||
var registry storeRegistry
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
}
|
||||
|
||||
s.cache = ®istry
|
||||
s.cacheTime = time.Now()
|
||||
s.saveDiskCache()
|
||||
|
||||
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
|
||||
registry, err := s.fetchRegistry(forceRefresh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
installed := make(map[string]string) // id -> version
|
||||
|
||||
if manager != nil {
|
||||
for _, ext := range manager.GetAllExtensions() {
|
||||
installed[ext.ID] = ext.Manifest.Version
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||
|
||||
result := make([]storeExtensionResponse, 0, len(registry.Extensions))
|
||||
for i := range registry.Extensions {
|
||||
ext := ®istry.Extensions[i]
|
||||
resp := ext.toResponse()
|
||||
if installedVersion, ok := installed[ext.ID]; ok {
|
||||
resp.IsInstalled = true
|
||||
resp.InstalledVersion = installedVersion
|
||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||
}
|
||||
|
||||
result = append(result, resp)
|
||||
}
|
||||
|
||||
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.fetchRegistry(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ext *storeExtension
|
||||
for _, e := range registry.Extensions {
|
||||
if e.ID == extensionID {
|
||||
ext = &e
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ext == nil {
|
||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||
resp, err := client.Get(ext.getDownloadURL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
os.Remove(destPath)
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
|
||||
//
|
||||
// Accepted formats:
|
||||
// - https://raw.githubusercontent.com/owner/repo/<branch>/registry.json → returned as-is
|
||||
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
|
||||
// the GitHub API to discover the default branch, then converted to the raw URL
|
||||
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
|
||||
func resolveRegistryURL(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", fmt.Errorf("registry URL is empty")
|
||||
}
|
||||
|
||||
// Already a fully-qualified raw URL – keep it.
|
||||
if strings.Contains(input, "raw.githubusercontent.com") {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
const ghPrefix = "https://github.com/"
|
||||
if !strings.HasPrefix(input, ghPrefix) {
|
||||
// Also accept http:// and upgrade silently.
|
||||
const ghPrefixHTTP = "http://github.com/"
|
||||
if strings.HasPrefix(input, ghPrefixHTTP) {
|
||||
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
||||
} else {
|
||||
// Not a GitHub URL – return as-is.
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
|
||||
path := input[len(ghPrefix):]
|
||||
parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
|
||||
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", fmt.Errorf("invalid GitHub URL: expected github.com/<owner>/<repo>")
|
||||
}
|
||||
owner := parts[0]
|
||||
repo := strings.TrimSuffix(parts[1], ".git")
|
||||
|
||||
branch := resolveGitHubDefaultBranch(owner, repo)
|
||||
|
||||
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
|
||||
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
|
||||
// default branch. Falls back to "main" on any error.
|
||||
func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||
|
||||
resp, err := client.Get(apiURL)
|
||||
if err != nil {
|
||||
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err)
|
||||
return "main"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo)
|
||||
return "main"
|
||||
}
|
||||
|
||||
var info struct {
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
|
||||
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo)
|
||||
return "main"
|
||||
}
|
||||
|
||||
return info.DefaultBranch
|
||||
}
|
||||
|
||||
func requireHTTPSURL(rawURL string, context string) error {
|
||||
if rawURL == "" {
|
||||
return fmt.Errorf("%s URL is empty", context)
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *extensionStore) getCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
CategoryDownload,
|
||||
CategoryUtility,
|
||||
CategoryLyrics,
|
||||
CategoryIntegration,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
|
||||
extensions, err := s.getExtensionsWithStatus(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if query == "" && category == "" {
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
result := make([]storeExtensionResponse, 0, len(extensions))
|
||||
queryLower := toLower(query)
|
||||
|
||||
for _, ext := range extensions {
|
||||
if category != "" && ext.Category != category {
|
||||
continue
|
||||
}
|
||||
|
||||
if query != "" {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, ext)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *extensionStore) clearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
s.cache = nil
|
||||
s.cacheTime = time.Time{}
|
||||
|
||||
if s.cacheDir != "" {
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.Remove(cachePath)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Cache cleared")
|
||||
}
|
||||
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
return containsStr(toLower(s), substr)
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
result[i] = c
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestParseManifest_Valid(t *testing.T) {
|
||||
validManifest := `{
|
||||
"name": "test-provider",
|
||||
"displayName": "Test Provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"],
|
||||
"permissions": {
|
||||
"network": ["api.test.com"],
|
||||
"storage": true
|
||||
}
|
||||
}`
|
||||
|
||||
manifest, err := ParseManifest([]byte(validManifest))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected valid manifest to parse, got error: %v", err)
|
||||
}
|
||||
|
||||
if manifest.Name != "test-provider" {
|
||||
t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name)
|
||||
}
|
||||
|
||||
if manifest.Version != "1.0.0" {
|
||||
t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version)
|
||||
}
|
||||
|
||||
if !manifest.IsMetadataProvider() {
|
||||
t.Error("Expected IsMetadataProvider() to return true")
|
||||
}
|
||||
|
||||
if manifest.IsDownloadProvider() {
|
||||
t.Error("Expected IsDownloadProvider() to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_MissingName(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"]
|
||||
}`
|
||||
|
||||
_, err := ParseManifest([]byte(invalidManifest))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_MissingType(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"name": "test-provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension"
|
||||
}`
|
||||
|
||||
_, err := ParseManifest([]byte(invalidManifest))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDomainAllowed(t *testing.T) {
|
||||
manifest := &ExtensionManifest{
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.test.com", "*.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
domain string
|
||||
expected bool
|
||||
}{
|
||||
{"api.test.com", true},
|
||||
{"api.example.com", true},
|
||||
{"sub.example.com", true},
|
||||
{"notallowed.com", false},
|
||||
{"test.com", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := manifest.IsDomainAllowed(tt.domain)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
// Create a mock extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.allowed.com", "*.wildcard.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil {
|
||||
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||
t.Error("Expected blocked.com to be denied")
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||
t.Error("Expected notallowed.com to be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: true,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
validPath, err := runtime.validatePath("test.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||
}
|
||||
if validPath == "" {
|
||||
t.Error("Expected non-empty path")
|
||||
}
|
||||
|
||||
_, err = runtime.validatePath("../../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("Expected path traversal to be blocked")
|
||||
}
|
||||
|
||||
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||
}
|
||||
if nestedPath == "" {
|
||||
t.Error("Expected non-empty nested path")
|
||||
}
|
||||
|
||||
var absPath string
|
||||
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||
absPath = "C:\\Windows\\System32\\test.txt"
|
||||
} else {
|
||||
absPath = "/etc/passwd"
|
||||
}
|
||||
_, err = runtime.validatePath(absPath)
|
||||
if err == nil {
|
||||
t.Error("Expected absolute path to be blocked")
|
||||
}
|
||||
|
||||
extNoFile := &LoadedExtension{
|
||||
ID: "test-ext-no-file",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext-no-file",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: false,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
}
|
||||
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
||||
_, err = runtimeNoFile.validatePath("test.txt")
|
||||
if err == nil {
|
||||
t.Error("Expected file access to be denied without file permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
|
||||
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("base64Encode failed: %v", err)
|
||||
}
|
||||
if result.String() != "aGVsbG8=" {
|
||||
t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String())
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`)
|
||||
if err != nil {
|
||||
t.Fatalf("base64Decode failed: %v", err)
|
||||
}
|
||||
if result.String() != "hello" {
|
||||
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.md5("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("md5 failed: %v", err)
|
||||
}
|
||||
if result.String() != "5d41402abc4b2a76b9719d911017c592" {
|
||||
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||
if err != nil {
|
||||
t.Fatalf("stringifyJSON failed: %v", err)
|
||||
}
|
||||
// JSON output may vary in order, just check it's valid
|
||||
if result.String() == "" {
|
||||
t.Error("Expected non-empty JSON string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
// Create extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
privateIPs := []string{
|
||||
"http://localhost/admin",
|
||||
"http://127.0.0.1/admin",
|
||||
"http://192.168.1.1/admin",
|
||||
"http://10.0.0.1/admin",
|
||||
"http://172.16.0.1/admin",
|
||||
"http://169.254.169.254/latest/meta-data/", // AWS metadata
|
||||
"http://router.local/admin",
|
||||
}
|
||||
|
||||
for _, url := range privateIPs {
|
||||
err := runtime.validateDomain(url)
|
||||
if err == nil {
|
||||
t.Errorf("Expected private IP/host '%s' to be blocked", url)
|
||||
}
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
expected bool
|
||||
}{
|
||||
{"localhost", true},
|
||||
{"127.0.0.1", true},
|
||||
{"127.0.0.2", true},
|
||||
{"10.0.0.1", true},
|
||||
{"10.255.255.255", true},
|
||||
{"172.16.0.1", true},
|
||||
{"172.31.255.255", true},
|
||||
{"192.168.0.1", true},
|
||||
{"192.168.255.255", true},
|
||||
{"169.254.169.254", true},
|
||||
{"router.local", true},
|
||||
{"mydevice.local", true},
|
||||
|
||||
{"8.8.8.8", false},
|
||||
{"1.1.1.1", false},
|
||||
{"api.example.com", false},
|
||||
{"google.com", false},
|
||||
{"172.15.0.1", false},
|
||||
{"172.32.0.1", false},
|
||||
{"192.167.0.1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := isPrivateIP(tt.host)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type JSExecutionError struct {
|
||||
Message string
|
||||
IsTimeout bool
|
||||
}
|
||||
|
||||
func (e *JSExecutionError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultJSTimeout
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
value goja.Value
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
|
||||
var interrupted bool
|
||||
var interruptMu sync.Mutex
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
interruptMu.Lock()
|
||||
wasInterrupted := interrupted
|
||||
interruptMu.Unlock()
|
||||
|
||||
if wasInterrupted {
|
||||
resultCh <- result{nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded",
|
||||
IsTimeout: true,
|
||||
}}
|
||||
} else {
|
||||
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
val, err := vm.RunString(script)
|
||||
resultCh <- result{val, err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.value, res.err
|
||||
case <-ctx.Done():
|
||||
interruptMu.Lock()
|
||||
interrupted = true
|
||||
interruptMu.Unlock()
|
||||
|
||||
vm.Interrupt("execution timeout")
|
||||
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded",
|
||||
IsTimeout: true,
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
||||
// This should be used when you want to continue using the VM after a timeout
|
||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeout(vm, script, timeout)
|
||||
|
||||
// Clear any interrupt state so VM can be reused
|
||||
vm.ClearInterrupt()
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func IsTimeoutError(err error) bool {
|
||||
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||
return jsErr.IsTimeout
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,84 +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 {
|
||||
// Replace invalid characters with underscore
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
|
||||
// Remove leading/trailing spaces and dots
|
||||
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
sanitized = strings.Trim(sanitized, ".")
|
||||
|
||||
// Collapse multiple underscores
|
||||
multiUnderscore := regexp.MustCompile(`_+`)
|
||||
|
||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||
|
||||
// Limit length (Android has 255 byte limit for filenames)
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
}
|
||||
|
||||
// Ensure not empty
|
||||
|
||||
if sanitized == "" {
|
||||
sanitized = "untitled"
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
// Replace placeholders
|
||||
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 {
|
||||
// Trim leading/trailing whitespace to prevent filename issues
|
||||
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
|
||||
}
|
||||
|
||||
@@ -98,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,18 +1,29 @@
|
||||
module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.24.5
|
||||
toolchain go1.25.7
|
||||
|
||||
require (
|
||||
github.com/go-flac/flacpicture v0.3.0
|
||||
github.com/go-flac/flacvorbis v0.2.0
|
||||
github.com/go-flac/go-flac v1.0.0
|
||||
github.com/dop251/goja v0.0.0-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
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,14 +1,50 @@
|
||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
||||
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
||||
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-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=
|
||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,48 +1,53 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTP utility functions for consistent request handling across all downloaders
|
||||
|
||||
// User-Agent pool for Android Chrome browsers
|
||||
var userAgentTemplates = []string{
|
||||
"Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
||||
}
|
||||
|
||||
// getRandomUserAgent generates a random browser-like User-Agent string (Android Chrome format)
|
||||
func getRandomUserAgent() string {
|
||||
template := userAgentTemplates[rand.Intn(len(userAgentTemplates))]
|
||||
|
||||
androidVersion := rand.Intn(5) + 10 // Android 10-14
|
||||
deviceModel := rand.Intn(900) + 100 // Random model number
|
||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||
chromeBuild := rand.Intn(5000) + 5000
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
chromeBuild := rand.Intn(1500) + 6000
|
||||
chromePatch := rand.Intn(200) + 100
|
||||
|
||||
return fmt.Sprintf(template, androidVersion, deviceModel, chromeVersion, chromeBuild, chromePatch)
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||
chromeVersion,
|
||||
chromeBuild,
|
||||
chromePatch,
|
||||
)
|
||||
}
|
||||
|
||||
// Default timeout values
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
||||
DefaultMaxRetries = 3 // Default retry count
|
||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||
DefaultTimeout = 60 * time.Second
|
||||
DownloadTimeout = 24 * time.Hour
|
||||
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,
|
||||
@@ -54,51 +59,184 @@ var sharedTransport = &http.Transport{
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
|
||||
// Isolated from download traffic so that download failures cannot poison
|
||||
// the connection pool used by metadata enrichment.
|
||||
var metadataTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 30,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
MaxConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 32 * 1024,
|
||||
ReadBufferSize: 32 * 1024,
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
// Shared HTTP client for general requests (reuses connections)
|
||||
var sharedClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
// Shared HTTP client for downloads (longer timeout, reuses connections)
|
||||
var downloadClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
Timeout: DownloadTimeout,
|
||||
}
|
||||
|
||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||
// Uses shared transport for connection reuse
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSharedClient returns the shared HTTP client for general requests
|
||||
func GetSharedClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
// GetDownloadClient returns the shared HTTP client for downloads
|
||||
func GetDownloadClient() *http.Client {
|
||||
return downloadClient
|
||||
}
|
||||
|
||||
// CloseIdleConnections closes idle connections in the shared transport
|
||||
// Call this periodically during large batch downloads to prevent connection buildup
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
metadataTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||
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
|
||||
}
|
||||
|
||||
// Also checks for ISP blocking on errors
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
return client.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// RetryConfig holds configuration for retry logic
|
||||
@@ -109,7 +247,6 @@ type RetryConfig struct {
|
||||
BackoffFactor float64
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns default retry configuration
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: DefaultMaxRetries,
|
||||
@@ -119,33 +256,35 @@ func DefaultRetryConfig() RetryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||
var lastErr error
|
||||
delay := config.InitialDelay
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
// Clone request for retry (body needs to be re-readable)
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(reqCopy)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
|
||||
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
|
||||
}
|
||||
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||
attempt+1, config.MaxRetries+1, err, delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Success
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Handle rate limiting (429)
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
retryAfter := getRetryAfterDuration(resp)
|
||||
@@ -154,53 +293,68 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
}
|
||||
lastErr = fmt.Errorf("rate limited (429)")
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Server errors (5xx) - retry
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
|
||||
ispBlockingIndicators := []string{
|
||||
"blocked", "forbidden", "access denied", "not available in your",
|
||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||
}
|
||||
|
||||
for _, indicator := range ispBlockingIndicators {
|
||||
if strings.Contains(bodyStr, indicator) {
|
||||
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
||||
LogError("HTTP", "Domain: %s", req.URL.Host)
|
||||
LogError("HTTP", "Response contains: %s", indicator)
|
||||
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Client errors (4xx except 429) - don't retry
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// calculateNextDelay calculates the next delay with exponential backoff
|
||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||
if nextDelay > config.MaxDelay {
|
||||
nextDelay = config.MaxDelay
|
||||
}
|
||||
return nextDelay
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
||||
// Returns 60 seconds as default if header is missing or invalid
|
||||
// Returns 0 if the header is missing or invalid so callers can keep their
|
||||
// normal exponential backoff instead of stalling for an arbitrary minute.
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
return 60 * time.Second // Default wait time
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -208,11 +362,9 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
return 60 * time.Second // Default
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -230,7 +382,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")
|
||||
@@ -243,14 +394,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] + "..."
|
||||
}
|
||||
@@ -258,3 +407,157 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
type ISPBlockingError struct {
|
||||
Domain string
|
||||
Reason string
|
||||
OriginalErr error
|
||||
}
|
||||
|
||||
func (e *ISPBlockingError) Error() string {
|
||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||
}
|
||||
|
||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
domain := extractDomain(requestURL)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if opErr.Op == "dial" {
|
||||
var syscallErr syscall.Errno
|
||||
if errors.As(opErr.Err, &syscallErr) {
|
||||
switch syscallErr {
|
||||
case syscall.ECONNREFUSED:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ECONNRESET:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection reset - ISP may be intercepting traffic",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ETIMEDOUT:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection timed out - ISP may be blocking access",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ENETUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Network unreachable - ISP may be blocking route",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.EHOSTUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Host unreachable - ISP may be blocking destination",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tlsErr *tls.RecordHeaderError
|
||||
if errors.As(err, &tlsErr) {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
|
||||
blockingPatterns := []struct {
|
||||
pattern string
|
||||
reason string
|
||||
}{
|
||||
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
||||
{"connection refused", "Connection refused - port may be blocked"},
|
||||
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
||||
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
||||
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
||||
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
||||
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
||||
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
||||
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
||||
}
|
||||
|
||||
for _, bp := range blockingPatterns {
|
||||
if strings.Contains(errStr, bp.pattern) {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: bp.reason,
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
ispErr := IsISPBlocking(err, requestURL)
|
||||
if ispErr != nil {
|
||||
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
|
||||
LogError(tag, "Domain: %s", ispErr.Domain)
|
||||
LogError(tag, "Reason: %s", ispErr.Reason)
|
||||
LogError(tag, "Original error: %v", ispErr.OriginalErr)
|
||||
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractDomain(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||
return rawURL[:idx]
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
if parsed.Host != "" {
|
||||
return parsed.Host
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
||||
domain := extractDomain(requestURL)
|
||||
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build ios
|
||||
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
|
||||
// Fall back to standard HTTP client
|
||||
|
||||
// GetCloudflareBypassClient returns the standard HTTP client on iOS
|
||||
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
|
||||
func GetCloudflareBypassClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
// DoRequestWithCloudflareBypass on iOS just uses the standard client
|
||||
// uTLS Chrome fingerprint bypass is not available on iOS
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//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 {
|
||||
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
|
||||
}
|
||||
|
||||
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,849 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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"`
|
||||
MetadataFromFilename bool `json:"metadataFromFilename,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
|
||||
}
|
||||
|
||||
type scannedCueFileInfo struct {
|
||||
sheet *CueSheet
|
||||
audioPath string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
totalFiles := len(audioFileInfos)
|
||||
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)
|
||||
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||
|
||||
// First pass: scan .cue files to collect referenced audio paths
|
||||
for _, fileInfo := range audioFileInfos {
|
||||
filePath := fileInfo.path
|
||||
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 != "" {
|
||||
parsedCueFiles[filePath] = scannedCueFileInfo{
|
||||
sheet: sheet,
|
||||
audioPath: audioPath,
|
||||
}
|
||||
cueReferencedAudioFiles[audioPath] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, fileInfo := range audioFileInfos {
|
||||
filePath := fileInfo.path
|
||||
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" {
|
||||
var cueResults []LibraryScanResult
|
||||
cueInfo, ok := parsedCueFiles[filePath]
|
||||
if ok {
|
||||
cueResults, err = scanCueSheetForLibrary(
|
||||
filePath,
|
||||
cueInfo.sheet,
|
||||
cueInfo.audioPath,
|
||||
"",
|
||||
fileInfo.modTime,
|
||||
"",
|
||||
scanTime,
|
||||
)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
if cueReferencedAudioFiles[filePath] {
|
||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
||||
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) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||
|
||||
result := &LibraryScanResult{
|
||||
ID: generateLibraryID(filePath),
|
||||
FilePath: filePath,
|
||||
ScannedAt: scanTime,
|
||||
Format: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
if knownModTime > 0 {
|
||||
result.FileModTime = knownModTime
|
||||
} else if info, err := os.Stat(filePath); err == nil {
|
||||
result.FileModTime = info.ModTime().UnixMilli()
|
||||
}
|
||||
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" {
|
||||
coverPath, err := SaveCoverToCacheWithHintAndKey(
|
||||
filePath,
|
||||
displayNameHint,
|
||||
coverCacheDir,
|
||||
coverCacheKey,
|
||||
)
|
||||
if err == nil && coverPath != "" {
|
||||
result.CoverPath = coverPath
|
||||
}
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result, displayNameHint)
|
||||
case ".m4a":
|
||||
return scanM4AFile(filePath, result, displayNameHint)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result, displayNameHint)
|
||||
case ".opus", ".ogg":
|
||||
return scanOggFile(filePath, result, displayNameHint)
|
||||
default:
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if ext != "" {
|
||||
return ext
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(displayNameHint))
|
||||
}
|
||||
|
||||
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
|
||||
if displayNameHint != "" {
|
||||
return displayNameHint
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
|
||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
}
|
||||
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, displayNameHint, 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, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
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
|
||||
if result.ReleaseDate == "" {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
}
|
||||
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, displayNameHint, 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, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadOggVorbisComments(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, displayNameHint, 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, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
result.MetadataFromFilename = true
|
||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||
|
||||
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 == "fd" || result.AlbumName == "self" {
|
||||
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) {
|
||||
return ReadAudioMetadataWithDisplayName(filePath, "")
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "")
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
|
||||
filePath,
|
||||
displayNameHint,
|
||||
coverCacheKey,
|
||||
scanTime,
|
||||
0,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||
existingFiles := make(map[string]int64)
|
||||
if snapshotPath == "" {
|
||||
return existingFiles, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(snapshotPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
modTime, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
existingFiles[parts[1]] = modTime
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existingFiles, nil
|
||||
}
|
||||
|
||||
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (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)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
var filesToScan []libraryAudioFileInfo
|
||||
skippedCount := 0
|
||||
existingCueTrackModTimes := make(map[string]int64)
|
||||
for existingPath, modTime := range existingFiles {
|
||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||
baseCuePath := existingPath[:idx]
|
||||
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
|
||||
existingCueTrackModTimes[baseCuePath] = modTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range currentFiles {
|
||||
existingModTime, exists := existingFiles[f.path]
|
||||
if !exists {
|
||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||
if f.modTime == cueTrackModTime {
|
||||
skippedCount++
|
||||
} else {
|
||||
filesToScan = append(filesToScan, f)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else if f.modTime != existingModTime {
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else {
|
||||
skippedCount++
|
||||
}
|
||||
}
|
||||
|
||||
var deletedPaths []string
|
||||
for existingPath := range existingFiles {
|
||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||
baseCuePath := existingPath[:idx]
|
||||
if currentPathSet[baseCuePath] {
|
||||
continue
|
||||
}
|
||||
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
|
||||
|
||||
cueReferencedAudioFilesInc := make(map[string]bool)
|
||||
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||
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 != "" {
|
||||
parsedCueFiles[f.path] = scannedCueFileInfo{
|
||||
sheet: sheet,
|
||||
audioPath: 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))
|
||||
|
||||
if ext == ".cue" {
|
||||
var cueResults []LibraryScanResult
|
||||
cueInfo, ok := parsedCueFiles[f.path]
|
||||
if ok {
|
||||
cueResults, err = scanCueSheetForLibrary(
|
||||
f.path,
|
||||
cueInfo.sheet,
|
||||
cueInfo.audioPath,
|
||||
"",
|
||||
f.modTime,
|
||||
"",
|
||||
scanTime,
|
||||
)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
if cueReferencedAudioFilesInc[f.path] {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
|
||||
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
|
||||
if err != nil {
|
||||
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
|
||||
}
|
||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScanFromFilenameMarksMetadataFallback(t *testing.T) {
|
||||
result := &LibraryScanResult{}
|
||||
|
||||
scanned, err := scanFromFilename(
|
||||
"/proc/self/fd/209",
|
||||
"189.mp3",
|
||||
result,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("scanFromFilename returned error: %v", err)
|
||||
}
|
||||
if !scanned.MetadataFromFilename {
|
||||
t.Fatal("expected filename fallback marker to be set")
|
||||
}
|
||||
if scanned.TrackName != "189" {
|
||||
t.Fatalf("unexpected track name: %q", scanned.TrackName)
|
||||
}
|
||||
if scanned.ArtistName != "Unknown Artist" {
|
||||
t.Fatalf("unexpected artist name: %q", scanned.ArtistName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Tag string `json:"tag"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type LogBuffer struct {
|
||||
entries []LogEntry
|
||||
maxSize int
|
||||
mu sync.RWMutex
|
||||
loggingEnabled bool
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLogBufferSize = 500
|
||||
)
|
||||
|
||||
var (
|
||||
globalLogBuffer *LogBuffer
|
||||
logBufferOnce sync.Once
|
||||
|
||||
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
|
||||
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
|
||||
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
)
|
||||
|
||||
func sanitizeSensitiveLogText(message string) string {
|
||||
redacted := message
|
||||
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
|
||||
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
|
||||
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
|
||||
return redacted
|
||||
}
|
||||
|
||||
func GetLogBuffer() *LogBuffer {
|
||||
logBufferOnce.Do(func() {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||
maxSize: defaultLogBufferSize,
|
||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||
}
|
||||
})
|
||||
return globalLogBuffer
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.loggingEnabled = enabled
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return lb.loggingEnabled
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
|
||||
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||
return
|
||||
}
|
||||
|
||||
message = sanitizeSensitiveLogText(message)
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format("15:04:05.000"),
|
||||
Level: level,
|
||||
Tag: tag,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if len(lb.entries) >= lb.maxSize {
|
||||
lb.entries = lb.entries[1:]
|
||||
}
|
||||
lb.entries = append(lb.entries, entry)
|
||||
|
||||
fmt.Printf("[%s] %s\n", tag, message)
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) GetAll() string {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
|
||||
jsonBytes, _ := json.Marshal(lb.entries)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
if index >= len(lb.entries) {
|
||||
return []LogEntry{}, len(lb.entries)
|
||||
}
|
||||
|
||||
entries := lb.entries[index:]
|
||||
return entries, len(lb.entries)
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) Clear() {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.entries = lb.entries[:0]
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) Count() int {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return len(lb.entries)
|
||||
}
|
||||
|
||||
func LogDebug(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func LogInfo(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func LogWarn(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func LogError(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
||||
// It parses the tag from the format string if it starts with [Tag]
|
||||
func GoLog(format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
message = strings.TrimSuffix(message, "\n")
|
||||
|
||||
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||
tag := "Go"
|
||||
level := "INFO"
|
||||
|
||||
if strings.HasPrefix(message, "[") {
|
||||
endBracket := strings.Index(message, "]")
|
||||
if endBracket > 1 {
|
||||
tag = message[1:endBracket]
|
||||
message = strings.TrimSpace(message[endBracket+1:])
|
||||
}
|
||||
}
|
||||
|
||||
// Determine level from message content
|
||||
msgLower := strings.ToLower(message)
|
||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
||||
level = "ERROR"
|
||||
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||
level = "WARN"
|
||||
} else if strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||
level = "INFO"
|
||||
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||
level = "DEBUG"
|
||||
}
|
||||
|
||||
GetLogBuffer().Add(level, tag, message)
|
||||
}
|
||||
|
||||
func GetLogs() string {
|
||||
return GetLogBuffer().GetAll()
|
||||
}
|
||||
|
||||
func GetLogsSince(index int) string {
|
||||
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||
logsJson, _ := json.Marshal(entries)
|
||||
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
|
||||
return result
|
||||
}
|
||||
|
||||
func ClearLogs() {
|
||||
GetLogBuffer().Clear()
|
||||
}
|
||||
|
||||
func GetLogCount() int {
|
||||
return GetLogBuffer().Count()
|
||||
}
|
||||
|
||||
func SetLoggingEnabled(enabled bool) {
|
||||
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||
}
|
||||
@@ -3,14 +3,240 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func GetLyricsProviderOrder() []string {
|
||||
lyricsProvidersMu.RLock()
|
||||
defer lyricsProvidersMu.RUnlock()
|
||||
|
||||
if len(lyricsProviders) == 0 {
|
||||
return DefaultLyricsProviders
|
||||
}
|
||||
|
||||
result := make([]string, len(lyricsProviders))
|
||||
copy(result, lyricsProviders)
|
||||
return result
|
||||
}
|
||||
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||
lyricsFetchOptionsMu.RLock()
|
||||
defer lyricsFetchOptionsMu.RUnlock()
|
||||
return lyricsFetchOptions
|
||||
}
|
||||
|
||||
type lyricsCacheEntry struct {
|
||||
response *LyricsResponse
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type lyricsCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*lyricsCacheEntry
|
||||
}
|
||||
|
||||
var globalLyricsCache = &lyricsCache{
|
||||
cache: make(map[string]*lyricsCacheEntry),
|
||||
}
|
||||
|
||||
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
|
||||
roundedDuration := math.Round(durationSec/10) * 10
|
||||
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
|
||||
}
|
||||
|
||||
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
key := c.generateKey(artist, track, durationSec)
|
||||
entry, exists := c.cache[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.response, true
|
||||
}
|
||||
|
||||
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
key := c.generateKey(artist, track, durationSec)
|
||||
c.cache[key] = &lyricsCacheEntry{
|
||||
response: response,
|
||||
expiresAt: time.Now().Add(lyricsCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *lyricsCache) CleanExpired() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cleaned := 0
|
||||
for key, entry := range c.cache {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(c.cache, key)
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func (c *lyricsCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -23,6 +249,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"`
|
||||
@@ -44,9 +282,7 @@ type LyricsClient struct {
|
||||
|
||||
func NewLyricsClient() *LyricsClient {
|
||||
return &LyricsClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +298,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 {
|
||||
@@ -86,7 +322,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
||||
return c.parseLRCLibResponse(&lrcResp), nil
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
|
||||
baseURL := "https://lrclib.net/api/search"
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
@@ -97,7 +333,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
||||
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 {
|
||||
@@ -118,6 +354,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
||||
return nil, fmt.Errorf("no lyrics found")
|
||||
}
|
||||
|
||||
bestMatch := c.findBestMatch(results, durationSec)
|
||||
if bestMatch != nil {
|
||||
return c.parseLRCLibResponse(bestMatch), nil
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if result.SyncedLyrics != "" {
|
||||
return c.parseLRCLibResponse(&result), nil
|
||||
@@ -127,43 +368,440 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
||||
return c.parseLRCLibResponse(&results[0]), nil
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
|
||||
// Strategy 1: Direct match with artist and track name
|
||||
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
func 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 buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
if syncType == "" {
|
||||
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
|
||||
syncType = "LINE_SYNCED"
|
||||
} else {
|
||||
syncType = "UNSYNCED"
|
||||
}
|
||||
}
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: syncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: plainLyrics,
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func plainLyricsFromTimedLines(lines []LyricsLine) string {
|
||||
parts := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, words)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
trimmed := strings.TrimSpace(lrcPayload)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(trimmed)
|
||||
if len(lines) > 0 {
|
||||
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(trimmed)
|
||||
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.Unmarshal(body, &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)
|
||||
}
|
||||
|
||||
lines := make([]LyricsLine, 0, len(apiResp.Lines))
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < len(lines)-1; i++ {
|
||||
nextStart := lines[i+1].StartTimeMs
|
||||
if nextStart > lines[i].StartTimeMs {
|
||||
lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
last := len(lines) - 1
|
||||
if lines[last].EndTimeMs == 0 {
|
||||
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
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://lyrics.paxsenix.org/spotify/lyrics?id=%s", 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()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
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.Unmarshal(bodyBytes, &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)
|
||||
}
|
||||
|
||||
return parseSpotifyLyricsResponseBody(bodyBytes)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
|
||||
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
|
||||
|
||||
if durationMatches {
|
||||
if result.SyncedLyrics != "" && bestSynced == nil {
|
||||
bestSynced = result
|
||||
} else if result.PlainLyrics != "" && bestPlain == nil {
|
||||
bestPlain = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestSynced != nil {
|
||||
return bestSynced
|
||||
}
|
||||
return bestPlain
|
||||
}
|
||||
|
||||
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||
diff := math.Abs(lrcDuration - targetDuration)
|
||||
return diff <= durationToleranceSec
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
isValidResult := func(l *LyricsResponse) bool {
|
||||
return lyricsHasUsableText(l)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
providerOrder := GetLyricsProviderOrder()
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
|
||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Strategy 2: Try with simplified track name
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
// 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(artistName, simplifiedTrack)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Search with full query
|
||||
query := artistName + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Strategy 4: Search with simplified query
|
||||
// 5. Search with simplified track name
|
||||
if simplifiedTrack != trackName {
|
||||
query = artistName + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("lyrics not found from any source")
|
||||
return nil, fmt.Errorf("LRCLIB: no lyrics found")
|
||||
}
|
||||
|
||||
func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse {
|
||||
@@ -203,10 +841,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,
|
||||
@@ -227,6 +875,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)
|
||||
@@ -240,23 +961,35 @@ 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)
|
||||
}
|
||||
|
||||
func convertToLRC(lyrics *LyricsResponse) string {
|
||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
|
||||
builder.WriteString("\n")
|
||||
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
continue
|
||||
}
|
||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
builder.WriteString(timestamp)
|
||||
builder.WriteString(line.Words)
|
||||
@@ -264,6 +997,9 @@ func convertToLRC(lyrics *LyricsResponse) string {
|
||||
}
|
||||
} else {
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(line.Words)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
@@ -294,6 +1030,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,305 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses Paxsenix endpoints for search and lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type appleMusicSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||
if normalizedArtist == "" {
|
||||
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := 0
|
||||
|
||||
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
|
||||
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
|
||||
|
||||
switch {
|
||||
case candidateTrack == normalizedTrack:
|
||||
score += 50
|
||||
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||
score += 25
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidateArtist == normalizedArtist:
|
||||
score += 60
|
||||
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||
score += 30
|
||||
}
|
||||
|
||||
if durationSec > 0 && result.Duration > 0 {
|
||||
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
|
||||
if diff <= durationToleranceSec {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
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 != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp []appleMusicSearchResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
}
|
||||
|
||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.ID) == "" {
|
||||
return "", fmt.Errorf("no songs found on apple music")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(best.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, durationSec)
|
||||
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,192 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"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: "https://lyrics.paxsenix.org/musixmatch/lyrics",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return "", fmt.Errorf("empty track or artist name")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("t", trackName)
|
||||
params.Set("a", artistName)
|
||||
params.Set("type", lyricsType)
|
||||
params.Set("format", "lrc")
|
||||
if durationSec > 0 {
|
||||
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
|
||||
}
|
||||
if strings.TrimSpace(language) != "" {
|
||||
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
|
||||
}
|
||||
fullURL := c.baseURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("musixmatch request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||
if lrcPayload == "" {
|
||||
return "", fmt.Errorf("empty musixmatch lyrics payload")
|
||||
}
|
||||
return lrcPayload, nil
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
|
||||
return trimmed, nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode musixmatch response")
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if lang == "" {
|
||||
return nil, fmt.Errorf("invalid language")
|
||||
}
|
||||
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
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) {
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||
if localizedErr == nil {
|
||||
return localized, nil
|
||||
}
|
||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||
}
|
||||
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
|
||||
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 := "https://lyrics.paxsenix.org/netease/search"
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
|
||||
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 := "https://lyrics.paxsenix.org/netease/lyrics"
|
||||
params := url.Values{}
|
||||
params.Set("id", fmt.Sprintf("%d", songID))
|
||||
|
||||
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,142 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Uses Paxsenix metadata lookup for lyrics.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type qqLyricsMetadataRequest struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album,omitempty"`
|
||||
SongID int64 `json:"songid,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
type qqLyricsMetadataResponse struct {
|
||||
Lyrics []paxLyrics `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusicClient {
|
||||
return &QQMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
|
||||
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||
payload := qqLyricsMetadataRequest{
|
||||
Artist: []string{artistName},
|
||||
Title: trackName,
|
||||
}
|
||||
if durationSec > 0 {
|
||||
payload.Duration = int64(math.Round(durationSec))
|
||||
}
|
||||
|
||||
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "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
|
||||
}
|
||||
|
||||
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
var response qqLyricsMetadataResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
|
||||
}
|
||||
if len(response.Lyrics) == 0 {
|
||||
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||
}
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload {
|
||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||
lrcText = fallback
|
||||
} else {
|
||||
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")
|
||||
}
|
||||