Compare commits
508 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 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 | |||
| 278ebf3472 | |||
| 7ade57e010 | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| 65a152cada | |||
| 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 | |||
| 8c201b5b4a | |||
| 5e19178bc0 | |||
| 107d9ca007 | |||
| 423695c24d | |||
| 4633c7253a | |||
| 8ace180fa8 | |||
| b9c3f2f0dd | |||
| 81b0eede8c | |||
| eb0cdbeba8 | |||
| ee212a0e48 | |||
| 2073516666 | |||
| 9d479b61d6 | |||
| 203e6bc4eb | |||
| 5f1ffbee4e | |||
| 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 | |||
| c4bea124fb | |||
| c37410b5de | |||
| b90c94125c | |||
| 35532b0c73 | |||
| 4c09b988e4 | |||
| bcd718b178 | |||
| 2b9357cb6d | |||
| 26d84041c7 | |||
| 93b4047143 | |||
| 3dbd131e49 | |||
| 57cb575483 |
@@ -0,0 +1,4 @@
|
|||||||
|
github: zarzet
|
||||||
|
ko_fi: zarzet
|
||||||
|
buy_me_a_coffee: zarzet
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Extension API Feature Request (Alpha)
|
name: Extension API Feature Request
|
||||||
description: Request new API features or capabilities for extension development (Extension system is in alpha)
|
description: Request new API features or capabilities for extension development
|
||||||
title: "[Extension API]: "
|
title: "[Extension API]: "
|
||||||
labels: ["enhancement", "extension-api"]
|
labels: ["enhancement", "extension-api"]
|
||||||
body:
|
body:
|
||||||
@@ -15,7 +15,7 @@ body:
|
|||||||
label: Checklist
|
label: Checklist
|
||||||
description: Please confirm the following before submitting
|
description: Please confirm the following before submitting
|
||||||
options:
|
options:
|
||||||
- label: I have read the [Extension Development Guide](https://zarz.moe/docs)
|
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||||
required: true
|
required: true
|
||||||
- label: I have searched existing issues and this API feature hasn't been requested yet
|
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2 # Need previous commit to compare
|
fetch-depth: 2 # Need previous commit to compare
|
||||||
|
|
||||||
|
|||||||
@@ -60,23 +60,23 @@ jobs:
|
|||||||
df -h
|
df -h
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.21"
|
go-version: "1.25"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||||
@@ -169,17 +169,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.21"
|
go-version: "1.25"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
- name: Cache CocoaPods
|
- name: Cache CocoaPods
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ios/Pods
|
path: ios/Pods
|
||||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||||
@@ -194,7 +194,7 @@ jobs:
|
|||||||
working-directory: go_backend
|
working-directory: go_backend
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ../ios/Frameworks
|
mkdir -p ../ios/Frameworks
|
||||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
|
|
||||||
@@ -249,23 +249,6 @@ jobs:
|
|||||||
channel: "stable"
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
|
||||||
- name: Use iOS pubspec with FFmpeg plugin
|
|
||||||
run: |
|
|
||||||
cp pubspec.yaml pubspec_android_backup.yaml
|
|
||||||
cp pubspec_ios.yaml pubspec.yaml
|
|
||||||
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
|
|
||||||
|
|
||||||
# Swap FFmpeg service for iOS
|
|
||||||
- name: Use iOS FFmpeg service
|
|
||||||
run: |
|
|
||||||
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
|
|
||||||
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
|
|
||||||
# Update class name in the swapped file
|
|
||||||
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
|
|
||||||
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
|
|
||||||
echo "Swapped to iOS FFmpeg service"
|
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -312,7 +295,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||||
@@ -325,7 +308,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Extract changelog for version
|
||||||
id: changelog
|
id: changelog
|
||||||
@@ -345,6 +328,8 @@ jobs:
|
|||||||
CHANGELOG="See CHANGELOG.md for details."
|
CHANGELOG="See CHANGELOG.md for details."
|
||||||
else
|
else
|
||||||
echo "Found changelog content"
|
echo "Found changelog content"
|
||||||
|
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
||||||
|
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Save to file for multiline support
|
# Save to file for multiline support
|
||||||
@@ -353,13 +338,13 @@ jobs:
|
|||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
- name: Download iOS IPA
|
- name: Download iOS IPA
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
@@ -400,7 +385,7 @@ jobs:
|
|||||||
cat /tmp/release_body.txt
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||||
@@ -410,3 +395,135 @@ jobs:
|
|||||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
notify-telegram:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [get-version, create-release]
|
||||||
|
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Download Android APK
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: android-apk
|
||||||
|
path: ./release
|
||||||
|
|
||||||
|
- name: Download iOS IPA
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: ios-ipa
|
||||||
|
path: ./release
|
||||||
|
|
||||||
|
- name: Extract changelog for version
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
VERSION_NUM=${VERSION#v}
|
||||||
|
|
||||||
|
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
|
||||||
|
# Use tr -d '\r' to handle CRLF line endings from Windows
|
||||||
|
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
|
||||||
|
|
||||||
|
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
|
||||||
|
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
|
||||||
|
|
||||||
|
if [ -z "$FULL_CHANGELOG" ]; then
|
||||||
|
CHANGELOG="See release notes on GitHub for details."
|
||||||
|
else
|
||||||
|
# Convert GitHub Markdown to Telegram HTML:
|
||||||
|
# - **text** → <b>text</b>
|
||||||
|
# - `code` → <code>code</code>
|
||||||
|
# - ### Header → <b>Header</b>
|
||||||
|
# - Escape HTML special chars first
|
||||||
|
# - Remove > blockquote prefix
|
||||||
|
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
||||||
|
sed 's/^> //' | \
|
||||||
|
sed 's/&/\&/g' | \
|
||||||
|
sed 's/</\</g' | \
|
||||||
|
sed 's/>/\>/g' | \
|
||||||
|
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
||||||
|
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||||
|
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
|
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
|
sed 's/^- /• /g' | \
|
||||||
|
sed 's/^ - / ◦ /g')
|
||||||
|
|
||||||
|
# Take first 2500 characters, then cut at last complete line
|
||||||
|
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||||
|
|
||||||
|
# Check if truncated
|
||||||
|
FULL_LEN=${#FULL_CHANGELOG}
|
||||||
|
if [ $FULL_LEN -gt 2500 ]; then
|
||||||
|
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
|
echo "DEBUG: Final changelog:"
|
||||||
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
|
- name: Send to Telegram Channel
|
||||||
|
env:
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
CHANGELOG=$(cat /tmp/changelog.txt)
|
||||||
|
|
||||||
|
# Find APK files
|
||||||
|
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
|
||||||
|
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
|
||||||
|
|
||||||
|
# Prepare message with changelog (HTML format)
|
||||||
|
printf '%s\n' \
|
||||||
|
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
|
||||||
|
"" \
|
||||||
|
"<b>What's New:</b>" \
|
||||||
|
"${CHANGELOG}" \
|
||||||
|
"" \
|
||||||
|
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
|
||||||
|
> /tmp/telegram_message.txt
|
||||||
|
|
||||||
|
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||||
|
|
||||||
|
# Send message first (using HTML parse mode)
|
||||||
|
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
|
||||||
|
# Use || true to ensure file uploads continue even if message fails
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
--data-urlencode "text=${MESSAGE}" \
|
||||||
|
--data-urlencode "parse_mode=HTML" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true" || true
|
||||||
|
|
||||||
|
# Upload arm64 APK to channel
|
||||||
|
if [ -f "$ARM64_APK" ]; then
|
||||||
|
echo "Uploading arm64 APK to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
-F document=@"${ARM64_APK}" \
|
||||||
|
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload arm32 APK to channel
|
||||||
|
if [ -f "$ARM32_APK" ]; then
|
||||||
|
echo "Uploading arm32 APK to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
-F document=@"${ARM32_APK}" \
|
||||||
|
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload iOS IPA to channel
|
||||||
|
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
|
||||||
|
if [ -f "$IOS_IPA" ]; then
|
||||||
|
echo "Uploading iOS IPA to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
-F document=@"${IOS_IPA}" \
|
||||||
|
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Telegram notification sent!"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
.cursorignore
|
||||||
|
.cursorrules
|
||||||
|
|
||||||
# Kiro specs (development only)
|
# Kiro specs (development only)
|
||||||
.kiro/
|
.kiro/
|
||||||
@@ -13,7 +15,7 @@ Thumbs.db
|
|||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
# Documentation (hosted separately)
|
# Documentation (development only, published separately)
|
||||||
docs/
|
docs/
|
||||||
|
|
||||||
# Old spotiflac_android folder (moved to root)
|
# Old spotiflac_android folder (moved to root)
|
||||||
@@ -53,3 +55,21 @@ ios/.symlinks/
|
|||||||
ios/Flutter/Flutter.framework/
|
ios/Flutter/Flutter.framework/
|
||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
android/app/libs/gobackend-sources.jar
|
android/app/libs/gobackend-sources.jar
|
||||||
|
|
||||||
|
# Extension folder
|
||||||
|
extension/
|
||||||
|
|
||||||
|
# Agent instructions
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
|
# Temp/misc
|
||||||
|
nul
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
hs_err_*.log
|
||||||
|
flutter_*.log
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
tool/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||||
|
identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the overall
|
||||||
|
community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||||
|
any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email address,
|
||||||
|
without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official email address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
**[zarzet](https://github.com/zarzet)**.
|
||||||
|
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series of
|
||||||
|
actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or permanent
|
||||||
|
ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||||
|
community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||||
|
[https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# Contributing to SpotiFLAC
|
||||||
|
|
||||||
|
First off, thank you for considering contributing to SpotiFLAC! 🎉
|
||||||
|
|
||||||
|
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Code of Conduct](#code-of-conduct)
|
||||||
|
- [How Can I Contribute?](#how-can-i-contribute)
|
||||||
|
- [Reporting Bugs](#reporting-bugs)
|
||||||
|
- [Suggesting Features](#suggesting-features)
|
||||||
|
- [Code Contributions](#code-contributions)
|
||||||
|
- [Translations](#translations)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Coding Guidelines](#coding-guidelines)
|
||||||
|
- [Commit Guidelines](#commit-guidelines)
|
||||||
|
- [Pull Request Process](#pull-request-process)
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
||||||
|
|
||||||
|
## How Can I Contribute?
|
||||||
|
|
||||||
|
### Reporting Bugs
|
||||||
|
|
||||||
|
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
|
||||||
|
|
||||||
|
When creating a bug report, please use the bug report template and include:
|
||||||
|
|
||||||
|
- **Clear and descriptive title**
|
||||||
|
- **Steps to reproduce** the issue
|
||||||
|
- **Expected behavior** vs **actual behavior**
|
||||||
|
- **Screenshots or screen recordings** if applicable
|
||||||
|
- **Device information** (model, OS version)
|
||||||
|
- **App version**
|
||||||
|
- **Logs** from Settings > About > View Logs
|
||||||
|
|
||||||
|
### Suggesting Features
|
||||||
|
|
||||||
|
Feature requests are welcome! Please use the feature request template and:
|
||||||
|
|
||||||
|
- **Check existing issues** to avoid duplicates
|
||||||
|
- **Describe the feature** clearly
|
||||||
|
- **Explain the use case** - why would this be useful?
|
||||||
|
- **Consider the scope** - is this a small enhancement or a major feature?
|
||||||
|
|
||||||
|
### Code Contributions
|
||||||
|
|
||||||
|
1. **Fork the repository** and create your branch from `dev`
|
||||||
|
2. **Make your changes** following our coding guidelines
|
||||||
|
3. **Test your changes** thoroughly
|
||||||
|
4. **Submit a pull request** to the `dev` branch
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
|
||||||
|
|
||||||
|
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
2. Select your language or request a new one
|
||||||
|
3. Start translating!
|
||||||
|
|
||||||
|
Translation files are located in `lib/l10n/arb/`.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Flutter SDK** 3.10.0 or higher
|
||||||
|
- **Dart SDK** 3.10.0 or higher
|
||||||
|
- **Android Studio** or **VS Code** with Flutter extensions
|
||||||
|
- **Git**
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. **Clone your fork**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
||||||
|
cd SpotiFLAC-Mobile
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add upstream remote**
|
||||||
|
```bash
|
||||||
|
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||||
|
```bash
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run the app**
|
||||||
|
```bash
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug build
|
||||||
|
flutter build apk --debug
|
||||||
|
|
||||||
|
# Release build
|
||||||
|
flutter build apk --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── l10n/ # Localization files
|
||||||
|
│ └── arb/ # ARB translation files
|
||||||
|
├── models/ # Data models
|
||||||
|
├── providers/ # Riverpod providers
|
||||||
|
├── screens/ # UI screens
|
||||||
|
│ └── settings/ # Settings sub-screens
|
||||||
|
├── services/ # Business logic services
|
||||||
|
├── theme/ # App theming
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
├── widgets/ # Reusable widgets
|
||||||
|
├── app.dart # App configuration
|
||||||
|
└── main.dart # Entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Guidelines
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
|
||||||
|
- Use meaningful variable and function names
|
||||||
|
- Keep functions small and focused
|
||||||
|
- Add comments for complex logic
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
- Use `dart format` before committing
|
||||||
|
- Maximum line length: 80 characters
|
||||||
|
- Use trailing commas for better formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart format .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
Ensure your code passes all lints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
We use **Riverpod** for state management. Follow these patterns:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use code generation with riverpod_annotation
|
||||||
|
@riverpod
|
||||||
|
class MyNotifier extends _$MyNotifier {
|
||||||
|
@override
|
||||||
|
MyState build() => MyState();
|
||||||
|
|
||||||
|
// Methods to update state
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
|
||||||
|
All user-facing strings should be localized:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Good
|
||||||
|
Text(AppLocalizations.of(context)!.downloadComplete)
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
Text('Download Complete')
|
||||||
|
```
|
||||||
|
|
||||||
|
To add new strings:
|
||||||
|
1. Add the key to `lib/l10n/arb/app_en.arb`
|
||||||
|
2. Run `flutter gen-l10n`
|
||||||
|
|
||||||
|
## Commit Guidelines
|
||||||
|
|
||||||
|
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat`: New feature
|
||||||
|
- `fix`: Bug fix
|
||||||
|
- `docs`: Documentation changes
|
||||||
|
- `style`: Code style changes (formatting, etc.)
|
||||||
|
- `refactor`: Code refactoring
|
||||||
|
- `perf`: Performance improvements
|
||||||
|
- `test`: Adding or updating tests
|
||||||
|
- `chore`: Maintenance tasks
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(download): add batch download support
|
||||||
|
fix(ui): resolve overflow on small screens
|
||||||
|
docs: update contributing guidelines
|
||||||
|
chore(deps): update flutter_riverpod to 3.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
1. **Update your fork**
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git rebase upstream/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create a feature branch**
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/my-new-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Make your changes** and commit following our guidelines
|
||||||
|
|
||||||
|
4. **Push to your fork**
|
||||||
|
```bash
|
||||||
|
git push origin feat/my-new-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Create a Pull Request**
|
||||||
|
- Target the `dev` branch
|
||||||
|
- Fill in the PR template
|
||||||
|
- Link related issues
|
||||||
|
|
||||||
|
6. **Address review feedback**
|
||||||
|
- Make requested changes
|
||||||
|
- Push additional commits
|
||||||
|
- Request re-review when ready
|
||||||
|
|
||||||
|
### PR Requirements
|
||||||
|
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] All tests pass
|
||||||
|
- [ ] No new linting errors
|
||||||
|
- [ ] Documentation updated (if needed)
|
||||||
|
- [ ] Commit messages follow guidelines
|
||||||
|
- [ ] PR description is clear and complete
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
If you have questions, feel free to:
|
||||||
|
|
||||||
|
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
|
||||||
|
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
|
||||||
|
|
||||||
|
Thank you for contributing! 💚
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
|
[](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="icon.png" width="128" />
|
<img src="icon.png" width="128" />
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -23,37 +24,65 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Metadata Source
|
## Extensions
|
||||||
|
|
||||||
SpotiFLAC supports two metadata sources for searching tracks:
|
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||||
|
|
||||||
| Source | Pros | Cons |
|
### Installing Extensions
|
||||||
|--------|------|------|
|
1. Go to **Store** tab in the app
|
||||||
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
|
2. Browse and install extensions with one tap
|
||||||
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
|
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||||
|
4. Configure extension settings if needed
|
||||||
|
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||||
|
|
||||||
### Using Spotify
|
### Developing Extensions
|
||||||
To use Spotify as your search source without hitting rate limits:
|
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
||||||
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
|
|
||||||
2. Create an app to get your Client ID and Client Secret
|
|
||||||
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
|
|
||||||
4. Enter your Client ID and Secret
|
|
||||||
5. Change **Search Source** to Spotify
|
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [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 for Windows, macOS & Linux
|
||||||
|
|
||||||
|
## Telegram
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Why is my download failing with "Song not found"?**
|
||||||
|
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
|
||||||
|
|
||||||
|
**Q: Why are some tracks downloading in lower quality?**
|
||||||
|
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
||||||
|
|
||||||
|
**Q: Can I download playlists?**
|
||||||
|
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||||
|
|
||||||
|
**Q: Why do I need to grant storage permission?**
|
||||||
|
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
||||||
|
|
||||||
|
**Q: Is this app safe?**
|
||||||
|
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||||
|
|
||||||
|
**Q: Why is download not working in my country?**
|
||||||
|
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||||
|
|
||||||
|
|
||||||
|
### Want to support SpotiFLAC-Mobile?
|
||||||
|
|
||||||
|
_If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._
|
||||||
|
|
||||||
|
[](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
> **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.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
|
||||||
|
|
||||||
|
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
||||||
|
|
||||||
You are solely responsible for:
|
You are solely responsible for:
|
||||||
1. Ensuring your use of this software complies with your local laws.
|
1. Ensuring your use of this software complies with your local laws.
|
||||||
@@ -61,3 +90,17 @@ You are solely responsible for:
|
|||||||
3. Any legal consequences resulting from the misuse of this tool.
|
3. Any legal consequences resulting from the misuse of this tool.
|
||||||
|
|
||||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||||
|
|
||||||
|
|
||||||
|
## API Credits
|
||||||
|
|
||||||
|
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
|
||||||
|
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
|
||||||
|
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
|
||||||
|
- **Lyrics**: [LRCLib](https://lrclib.net)
|
||||||
|
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||||
|
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||||
|
|||||||
@@ -96,11 +96,13 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
|
|
||||||
// Include all AAR and JAR files from libs folder
|
// Include all AAR and JAR files from libs folder
|
||||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
-keep class io.flutter.view.** { *; }
|
-keep class io.flutter.view.** { *; }
|
||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-keep class io.flutter.plugins.** { *; }
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
-keep class io.flutter.embedding.** { *; }
|
||||||
|
|
||||||
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||||
-dontwarn com.google.android.play.core.splitcompat.**
|
-dontwarn com.google.android.play.core.splitcompat.**
|
||||||
@@ -14,13 +15,22 @@
|
|||||||
# Ignore missing javax.xml.stream (not used on Android)
|
# Ignore missing javax.xml.stream (not used on Android)
|
||||||
-dontwarn javax.xml.stream.**
|
-dontwarn javax.xml.stream.**
|
||||||
|
|
||||||
# Go backend (gobackend.aar)
|
# Go backend (gobackend.aar) - CRITICAL for release builds
|
||||||
-keep class gobackend.** { *; }
|
-keep class gobackend.** { *; }
|
||||||
-keep class go.** { *; }
|
-keep class go.** { *; }
|
||||||
|
-keep interface gobackend.** { *; }
|
||||||
|
-keepclassmembers class gobackend.** { *; }
|
||||||
|
|
||||||
|
# Go mobile binding internals
|
||||||
|
-keep class org.golang.** { *; }
|
||||||
|
-dontwarn org.golang.**
|
||||||
|
|
||||||
# FFmpeg Kit
|
# FFmpeg Kit
|
||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-keep class com.arthenica.smartexception.** { *; }
|
||||||
|
# FFmpeg Kit (new fork package)
|
||||||
|
-keep class com.antonkarpenko.ffmpegkit.** { *; }
|
||||||
|
-keep class com.antonkarpenko.smartexception.** { *; }
|
||||||
|
|
||||||
# Apache Tika (if used by FFmpeg)
|
# Apache Tika (if used by FFmpeg)
|
||||||
-dontwarn org.apache.tika.**
|
-dontwarn org.apache.tika.**
|
||||||
@@ -30,15 +40,77 @@
|
|||||||
native <methods>;
|
native <methods>;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Kotlin coroutines
|
# Kotlin coroutines - expanded rules
|
||||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||||
-keepclassmembers class kotlinx.coroutines.** {
|
-keepclassmembers class kotlinx.coroutines.** {
|
||||||
volatile <fields>;
|
volatile <fields>;
|
||||||
}
|
}
|
||||||
|
-keepclassmembernames class kotlinx.** {
|
||||||
|
volatile <fields>;
|
||||||
|
}
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
|
||||||
|
# Kotlin serialization
|
||||||
|
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||||
|
-dontwarn kotlin.**
|
||||||
|
-keep class kotlin.** { *; }
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
|
||||||
|
# Keep MainActivity and related classes
|
||||||
|
-keep class com.zarz.spotiflac.** { *; }
|
||||||
|
|
||||||
# Prevent R8 from removing metadata
|
# Prevent R8 from removing metadata
|
||||||
-keepattributes *Annotation*
|
-keepattributes *Annotation*
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
-keepattributes Signature
|
-keepattributes Signature
|
||||||
-keepattributes Exceptions
|
-keepattributes Exceptions
|
||||||
|
-keepattributes InnerClasses
|
||||||
|
-keepattributes EnclosingMethod
|
||||||
|
|
||||||
|
# JSON parsing (used by Go backend responses)
|
||||||
|
-keep class org.json.** { *; }
|
||||||
|
|
||||||
|
# Shared Preferences
|
||||||
|
-keep class androidx.datastore.** { *; }
|
||||||
|
-dontwarn androidx.datastore.**
|
||||||
|
|
||||||
|
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
|
||||||
|
# Path Provider
|
||||||
|
-keep class io.flutter.plugins.pathprovider.** { *; }
|
||||||
|
-keep class dev.flutter.pigeon.** { *; }
|
||||||
|
|
||||||
|
# Local Notifications
|
||||||
|
-keep class com.dexterous.** { *; }
|
||||||
|
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||||
|
|
||||||
|
# Receive Sharing Intent
|
||||||
|
-keep class com.kasem.receive_sharing_intent.** { *; }
|
||||||
|
|
||||||
|
# Permission Handler
|
||||||
|
-keep class com.baseflow.permissionhandler.** { *; }
|
||||||
|
|
||||||
|
# File Picker
|
||||||
|
-keep class com.mr.flutter.plugin.filepicker.** { *; }
|
||||||
|
|
||||||
|
# URL Launcher
|
||||||
|
-keep class io.flutter.plugins.urllauncher.** { *; }
|
||||||
|
|
||||||
|
# Share Plus
|
||||||
|
-keep class dev.fluttercommunity.plus.share.** { *; }
|
||||||
|
|
||||||
|
# Device Info Plus
|
||||||
|
-keep class dev.fluttercommunity.plus.device_info.** { *; }
|
||||||
|
|
||||||
|
# Open File
|
||||||
|
-keep class com.crazecoder.openfile.** { *; }
|
||||||
|
|
||||||
|
# Sqflite
|
||||||
|
-keep class com.tekartik.sqflite.** { *; }
|
||||||
|
|
||||||
|
# Dynamic Color
|
||||||
|
-keep class io.material.** { *; }
|
||||||
|
|
||||||
|
# Keep all Flutter plugin registrants
|
||||||
|
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
|
||||||
|
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
android:label="SpotiFLAC"
|
android:label="SpotiFLAC"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:usesCleartextTraffic="false"
|
||||||
android:usesCleartextTraffic="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:enableOnBackInvokedCallback="true">
|
android:localeConfig="@xml/locale_config">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Handle Spotify URL sharing -->
|
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
@@ -57,6 +57,33 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="https" android:host="open.spotify.com" />
|
<data android:scheme="https" android:host="open.spotify.com" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle Deezer deep links -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="www.deezer.com" />
|
||||||
|
<data android:scheme="https" android:host="deezer.com" />
|
||||||
|
<data android:scheme="https" android:host="deezer.page.link" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle Tidal deep links -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="tidal.com" />
|
||||||
|
<data android:scheme="https" android:host="listen.tidal.com" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle YouTube Music deep links -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="music.youtube.com" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Download Service -->
|
<!-- Download Service -->
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -22,7 +22,7 @@ subprojects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add desugaring dependency to all Android subprojects
|
// Add desugaring dependency to all Android subprojects
|
||||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
|||||||
@@ -1,2 +1,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
|
android.useAndroidX=true
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 81 KiB |
@@ -1,208 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
|
||||||
|
|
||||||
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
|
||||||
class FFmpegServiceIOS {
|
|
||||||
/// Execute FFmpeg command and return result
|
|
||||||
static Future<FFmpegResultIOS> _execute(String command) async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute(command);
|
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
final output = await session.getOutput() ?? '';
|
|
||||||
return FFmpegResultIOS(
|
|
||||||
success: ReturnCode.isSuccess(returnCode),
|
|
||||||
returnCode: returnCode?.getValue() ?? -1,
|
|
||||||
output: output,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('FFmpeg execute error: $e');
|
|
||||||
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert M4A (DASH segments) to FLAC
|
|
||||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
|
||||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
|
||||||
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(inputPath).delete();
|
|
||||||
} catch (_) {}
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC to MP3
|
|
||||||
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
|
||||||
final dir = File(inputPath).parent.path;
|
|
||||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
|
||||||
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
|
||||||
await Directory(outputDir).create(recursive: true);
|
|
||||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
|
||||||
|
|
||||||
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) return outputPath;
|
|
||||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC to M4A
|
|
||||||
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
|
||||||
final dir = File(inputPath).parent.path;
|
|
||||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
|
||||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
|
||||||
await Directory(outputDir).create(recursive: true);
|
|
||||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
|
||||||
|
|
||||||
String command;
|
|
||||||
if (codec == 'alac') {
|
|
||||||
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
|
||||||
} else {
|
|
||||||
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
if (result.success) return outputPath;
|
|
||||||
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed cover art to FLAC file
|
|
||||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
|
||||||
final tempOutput = '$flacPath.tmp';
|
|
||||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
return flacPath;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace file after cover embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) await tempFile.delete();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed metadata and cover art to FLAC file
|
|
||||||
/// Returns the file path on success, null on failure
|
|
||||||
static Future<String?> embedMetadata({
|
|
||||||
required String flacPath,
|
|
||||||
String? coverPath,
|
|
||||||
Map<String, String>? metadata,
|
|
||||||
}) async {
|
|
||||||
final tempOutput = '$flacPath.tmp';
|
|
||||||
|
|
||||||
// Construct command
|
|
||||||
final StringBuffer cmdBuffer = StringBuffer();
|
|
||||||
cmdBuffer.write('-i "$flacPath" ');
|
|
||||||
|
|
||||||
// Add cover input if available
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-i "$coverPath" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map audio stream
|
|
||||||
cmdBuffer.write('-map 0:a ');
|
|
||||||
|
|
||||||
// Map cover stream if available
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-map 1:0 ');
|
|
||||||
cmdBuffer.write('-c:v copy ');
|
|
||||||
cmdBuffer.write('-disposition:v attached_pic ');
|
|
||||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
|
||||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy audio codec (don't re-encode)
|
|
||||||
cmdBuffer.write('-c:a copy ');
|
|
||||||
|
|
||||||
// Add text metadata
|
|
||||||
if (metadata != null) {
|
|
||||||
metadata.forEach((key, value) {
|
|
||||||
// Sanitize value: escape double quotes
|
|
||||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
|
||||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('"$tempOutput" -y');
|
|
||||||
|
|
||||||
final command = cmdBuffer.toString();
|
|
||||||
_log.d('Executing FFmpeg command: $command');
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
return flacPath;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace file after metadata embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up temp file if exists
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) {
|
|
||||||
await tempFile.delete();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('Metadata/Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
|
||||||
static Future<bool> isAvailable() async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute('-version');
|
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
return ReturnCode.isSuccess(returnCode);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get FFmpeg version info
|
|
||||||
static Future<String?> getVersion() async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute('-version');
|
|
||||||
return await session.getOutput();
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FFmpegResultIOS {
|
|
||||||
final bool success;
|
|
||||||
final int returnCode;
|
|
||||||
final String output;
|
|
||||||
|
|
||||||
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -2,357 +2,168 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/base64"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
// Amazon API timeout and retry configuration for mobile networks
|
||||||
|
const (
|
||||||
|
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
|
||||||
|
amazonMaxRetries = 2 // Number of retry attempts
|
||||||
|
amazonRetryDelay = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
type AmazonDownloader struct {
|
type AmazonDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
regions []string // us, eu regions for DoubleDouble service
|
|
||||||
lastAPICallTime time.Time // Rate limiting: track last API call
|
|
||||||
apiCallCount int // Rate limiting: counter per minute
|
|
||||||
apiCallResetTime time.Time // Rate limiting: reset time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Amazon downloader instance for connection reuse
|
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
amazonDownloaderOnce sync.Once
|
||||||
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||||
type DoubleDoubleSubmitResponse struct {
|
type AfkarXYZResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
ID string `json:"id"`
|
Data struct {
|
||||||
|
DirectLink string `json:"direct_link"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
|
|
||||||
type DoubleDoubleStatusResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
FriendlyStatus string `json:"friendlyStatus"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Current struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
} `json:"current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// amazonArtistsMatch checks if the artist names are similar enough
|
|
||||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if one contains the other
|
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
|
||||||
// assume they're the same artist with different transliteration
|
|
||||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
|
||||||
foundASCII := amazonIsASCIIString(foundArtist)
|
|
||||||
if expectedASCII != foundASCII {
|
|
||||||
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// amazonIsASCIIString checks if a string contains only ASCII characters
|
|
||||||
func amazonIsASCIIString(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if r > 127 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
amazonDownloaderOnce.Do(func() {
|
amazonDownloaderOnce.Do(func() {
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
|
||||||
apiCallResetTime: time.Now(),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalAmazonDownloader
|
return globalAmazonDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForRateLimit implements rate limiting similar to PC version
|
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
|
||||||
// Max 9 requests per minute with 7 second delay between requests
|
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
|
||||||
func (a *AmazonDownloader) waitForRateLimit() {
|
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||||
amazonRateLimitMu.Lock()
|
|
||||||
defer amazonRateLimitMu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||||
// Reset counter every minute
|
if attempt > 0 {
|
||||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||||
a.apiCallCount = 0
|
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
|
||||||
a.apiCallResetTime = now
|
time.Sleep(delay)
|
||||||
}
|
|
||||||
|
|
||||||
// If we've hit the limit (9 requests per minute), wait until next minute
|
|
||||||
if a.apiCallCount >= 9 {
|
|
||||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
|
||||||
if waitTime > 0 {
|
|
||||||
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
a.apiCallCount = 0
|
|
||||||
a.apiCallResetTime = time.Now()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Add delay between requests (7 seconds like PC version)
|
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
|
||||||
if !a.lastAPICallTime.IsZero() {
|
if err == nil {
|
||||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
return downloadURL, fileName, nil
|
||||||
minDelay := 7 * time.Second
|
|
||||||
if timeSinceLastCall < minDelay {
|
|
||||||
waitTime := minDelay - timeSinceLastCall
|
|
||||||
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
errStr := err.Error()
|
||||||
|
|
||||||
|
// Check if error is retryable
|
||||||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
strings.Contains(errStr, "EOF") ||
|
||||||
|
strings.Contains(errStr, "status 5") ||
|
||||||
|
strings.Contains(errStr, "status 429")
|
||||||
|
|
||||||
|
if !isRetryable {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tracking
|
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||||
a.lastAPICallTime = time.Now()
|
|
||||||
a.apiCallCount++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
// doAfkarXYZRequest performs a single request to AfkarXYZ API
|
||||||
// Uses same service as PC version (doubledouble.top)
|
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
|
||||||
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||||
// DoubleDouble service regions (same as PC)
|
defer cancel()
|
||||||
// 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)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
// This uses submit → poll → download mechanism
|
if err != nil {
|
||||||
// Internal function - not exported to gomobile
|
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) {
|
|
||||||
var lastError error
|
|
||||||
|
|
||||||
for _, region := range a.regions {
|
|
||||||
GoLog("[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 with rate limiting
|
|
||||||
encodedURL := url.QueryEscape(amazonURL)
|
|
||||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
|
||||||
|
|
||||||
// Apply rate limiting before request (like PC version)
|
|
||||||
a.waitForRateLimit()
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", submitURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
fmt.Println("[Amazon] Submitting download request...")
|
|
||||||
|
|
||||||
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
|
||||||
var resp *http.Response
|
|
||||||
maxRetries := 3
|
|
||||||
for retry := 0; retry < maxRetries; retry++ {
|
|
||||||
resp, err = a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == 429 { // Too Many Requests
|
|
||||||
resp.Body.Close()
|
|
||||||
if retry < maxRetries-1 {
|
|
||||||
waitTime := 15 * time.Second
|
|
||||||
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lastError = fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
resp.Body.Close()
|
|
||||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success - break retry loop
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || lastError != nil {
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var submitResp DoubleDoubleSubmitResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
if !submitResp.Success || submitResp.ID == "" {
|
|
||||||
lastError = fmt.Errorf("submit request failed")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadID := submitResp.ID
|
|
||||||
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
|
||||||
|
|
||||||
// Step 2: Poll for completion
|
|
||||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
|
||||||
fmt.Println("[Amazon] Waiting for download to complete...")
|
|
||||||
|
|
||||||
maxWait := 300 * time.Second // 5 minutes max wait
|
|
||||||
elapsed := time.Duration(0)
|
|
||||||
pollInterval := 3 * time.Second
|
|
||||||
|
|
||||||
for elapsed < maxWait {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
elapsed += pollInterval
|
|
||||||
|
|
||||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
statusReq.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
statusResp, err := a.client.Do(statusReq)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("\r[Amazon] Status check failed, retrying...")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if statusResp.StatusCode != 200 {
|
|
||||||
statusResp.Body.Close()
|
|
||||||
fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var status DoubleDoubleStatusResponse
|
|
||||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
|
||||||
statusResp.Body.Close()
|
|
||||||
fmt.Printf("\r[Amazon] Invalid JSON response, retrying...")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
statusResp.Body.Close()
|
|
||||||
|
|
||||||
if status.Status == "done" {
|
|
||||||
fmt.Println("\n[Amazon] Download ready!")
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
|
||||||
return fileURL, trackName, artist, nil
|
|
||||||
|
|
||||||
} else if status.Status == "error" {
|
|
||||||
errorMsg := status.FriendlyStatus
|
|
||||||
if errorMsg == "" {
|
|
||||||
errorMsg = "Unknown error"
|
|
||||||
}
|
|
||||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
// Still processing
|
|
||||||
friendlyStatus := status.FriendlyStatus
|
|
||||||
if friendlyStatus == "" {
|
|
||||||
friendlyStatus = status.Status
|
|
||||||
}
|
|
||||||
fmt.Printf("\r[Amazon] %s...", friendlyStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if elapsed >= maxWait {
|
|
||||||
lastError = fmt.Errorf("download timeout")
|
|
||||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastError != nil {
|
|
||||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp AfkarXYZResponse
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
||||||
|
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := apiResp.Data.FileName
|
||||||
|
if fileName == "" {
|
||||||
|
fileName = "track.flac"
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||||
|
fileName = reg.ReplaceAllString(fileName, "")
|
||||||
|
|
||||||
|
return apiResp.Data.DirectLink, fileName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||||
// Initialize item progress (required for all downloads)
|
|
||||||
|
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||||
|
return downloadURL, fileName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
|
ctx = initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -361,6 +172,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -370,54 +184,50 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := openOutputForWrite(outputPath, outputFD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
written, err = io.Copy(pw, resp.Body)
|
written, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush buffer before checking for errors
|
|
||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
// Check for any errors
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file size if Content-Length was provided
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,68 +243,70 @@ type AmazonDownloadResult struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
LyricsLRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
|
||||||
// Uses DoubleDouble service (same as PC version)
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if !isSafOutput {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
|
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
amazonURL := ""
|
||||||
|
if req.ISRC != "" {
|
||||||
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
||||||
|
amazonURL = cached.AmazonURL
|
||||||
|
GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from SongLink
|
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
var availability *TrackAvailability
|
var availability *TrackAvailability
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
if amazonURL == "" {
|
||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||||
// Extract Deezer ID and use Deezer-based lookup
|
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
} else if req.SpotifyID != "" {
|
||||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
} else if req.SpotifyID != "" {
|
} else {
|
||||||
// Use Spotify ID
|
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
}
|
||||||
} else {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
if err != nil {
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
amazonURL = availability.AmazonURL
|
||||||
|
if req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if !isSafOutput && req.OutputDir != "." {
|
||||||
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 {
|
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download using DoubleDouble service (same as PC)
|
// Download using AfkarXYZ API
|
||||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify artist matches
|
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
|
||||||
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log match found
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
@@ -502,12 +314,18 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
var outputPath string
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
if isSafOutput {
|
||||||
|
outputPath = strings.TrimSpace(req.OutputPath)
|
||||||
// Check if file already exists
|
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
}
|
||||||
|
} else {
|
||||||
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
@@ -522,37 +340,35 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
req.TrackName,
|
req.TrackName,
|
||||||
req.ArtistName,
|
req.ArtistName,
|
||||||
req.EmbedLyrics,
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||||
|
}
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
|
||||||
if trackName != "" && artistName != "" {
|
|
||||||
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read existing metadata from downloaded file BEFORE embedding
|
|
||||||
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
|
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
|
actualDate := req.ReleaseDate
|
||||||
|
actualAlbum := req.AlbumName
|
||||||
|
actualTitle := req.TrackName
|
||||||
|
actualArtist := req.ArtistName
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
if metaErr == nil && existingMeta != nil {
|
||||||
// Use file metadata if it has valid track/disc numbers and request doesn't have them
|
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||||
@@ -561,72 +377,111 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
actualDiscNum = existingMeta.DiscNumber
|
actualDiscNum = existingMeta.DiscNumber
|
||||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||||
}
|
}
|
||||||
|
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||||
|
actualDate = existingMeta.Date
|
||||||
|
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||||
|
}
|
||||||
|
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||||
|
actualAlbum = existingMeta.Album
|
||||||
|
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||||
|
}
|
||||||
|
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||||
|
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
|
||||||
// But preserve track/disc numbers from file if they were better
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: actualTitle,
|
||||||
Artist: req.ArtistName,
|
Artist: actualArtist,
|
||||||
Album: req.AlbumName,
|
Album: actualAlbum,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
Date: actualDate,
|
||||||
TrackNumber: actualTrackNum,
|
TrackNumber: actualTrackNum,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
|
Genre: req.Genre,
|
||||||
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
}
|
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Embed lyrics from parallel fetch
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
} else if req.EmbedLyrics {
|
|
||||||
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
existingCover, coverErr := ExtractCoverArt(outputPath)
|
||||||
|
if coverErr == nil && len(existingCover) > 0 {
|
||||||
|
coverData = existingCover
|
||||||
|
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read metadata from file AFTER embedding to get accurate values
|
if isSafOutput {
|
||||||
// This ensures we return what's actually in the file
|
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
} else {
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
}
|
||||||
actualTrackNum = finalMeta.TrackNumber
|
|
||||||
actualDiscNum = finalMeta.DiscNumber
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
if finalMeta.Date != "" {
|
lyricsMode := req.LyricsMode
|
||||||
// Use date from file if available
|
if lyricsMode == "" {
|
||||||
req.ReleaseDate = finalMeta.Date
|
lyricsMode = "embed"
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
|
GoLog("[Amazon] Saving external LRC file...\n")
|
||||||
|
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
|
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||||
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||||
|
|
||||||
|
quality := AudioQuality{}
|
||||||
|
if isSafOutput {
|
||||||
|
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||||
|
} else {
|
||||||
|
quality, err = GetAudioQuality(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||||
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
|
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
|
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||||
|
actualTrackNum = finalMeta.TrackNumber
|
||||||
|
actualDiscNum = finalMeta.DiscNumber
|
||||||
|
if finalMeta.Date != "" {
|
||||||
|
req.ReleaseDate = finalMeta.Date
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking
|
// Add to ISRC index for fast duplicate checking
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
if !isSafOutput {
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
bitDepth := 0
|
bitDepth := 0
|
||||||
sampleRate := 0
|
sampleRate := 0
|
||||||
@@ -635,6 +490,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
sampleRate = quality.SampleRate
|
sampleRate = quality.SampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lyricsLRC := ""
|
||||||
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
|
}
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
@@ -646,5 +506,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
TrackNumber: actualTrackNum,
|
TrackNumber: actualTrackNum,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
|
LyricsLRC: lyricsLRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||||
|
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||||
|
|
||||||
|
type cancelEntry struct {
|
||||||
|
cancel context.CancelFunc
|
||||||
|
canceled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cancelMu sync.Mutex
|
||||||
|
cancelMap = make(map[string]*cancelEntry)
|
||||||
|
)
|
||||||
|
|
||||||
|
func initDownloadCancel(itemID string) context.Context {
|
||||||
|
if itemID == "" {
|
||||||
|
return context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelMu.Lock()
|
||||||
|
defer cancelMu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancelMap[itemID] = &cancelEntry{
|
||||||
|
cancel: cancel,
|
||||||
|
canceled: false,
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelDownload(itemID string) {
|
||||||
|
if itemID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelMu.Lock()
|
||||||
|
entry, ok := cancelMap[itemID]
|
||||||
|
if ok {
|
||||||
|
entry.canceled = true
|
||||||
|
if entry.cancel != nil {
|
||||||
|
entry.cancel()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cancelMap[itemID] = &cancelEntry{canceled: true}
|
||||||
|
}
|
||||||
|
cancelMu.Unlock()
|
||||||
|
|
||||||
|
RemoveItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDownloadCancelled(itemID string) bool {
|
||||||
|
if itemID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelMu.Lock()
|
||||||
|
entry, ok := cancelMap[itemID]
|
||||||
|
canceled := ok && entry.canceled
|
||||||
|
cancelMu.Unlock()
|
||||||
|
return canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearDownloadCancel(itemID string) {
|
||||||
|
if itemID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelMu.Lock()
|
||||||
|
delete(cancelMap, itemID)
|
||||||
|
cancelMu.Unlock()
|
||||||
|
}
|
||||||
@@ -4,36 +4,53 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Spotify image size codes (same as PC version)
|
|
||||||
const (
|
const (
|
||||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
spotifySize300 = "ab67616d00001e02"
|
||||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
spotifySize640 = "ab67616d0000b273"
|
||||||
|
spotifySizeMax = "ab67616d000082c1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
// This avoids file permission issues on Android
|
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||||
|
|
||||||
|
func convertSmallToMedium(imageURL string) string {
|
||||||
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
|
}
|
||||||
|
return imageURL
|
||||||
|
}
|
||||||
|
|
||||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
return nil, fmt.Errorf("no cover URL provided")
|
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 {
|
if maxQuality {
|
||||||
downloadURL = upgradeToMaxQuality(coverURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if downloadURL != coverURL {
|
if maxURL != downloadURL {
|
||||||
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
|
downloadURL = maxURL
|
||||||
|
// Log already printed by upgradeToMaxQuality for Deezer
|
||||||
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
|
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
|
|
||||||
// Create request with User-Agent (required by Spotify CDN)
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -54,48 +71,58 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
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
|
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 {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify image URLs can be upgraded by changing the size parameter
|
// Spotify CDN upgrade
|
||||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
|
||||||
// ab67616d0000b273 = 640x640
|
|
||||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
|
||||||
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
// Try max resolution first
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
}
|
||||||
|
|
||||||
// Verify max resolution URL is available
|
// Deezer CDN upgrade
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
req, err := http.NewRequest("HEAD", maxURL, nil)
|
return upgradeDeezerCover(coverURL)
|
||||||
if err == nil {
|
|
||||||
resp, err := DoRequestWithUserAgent(client, req)
|
|
||||||
if err == nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
return maxURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
func upgradeDeezerCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace any size pattern with 1800x1800
|
||||||
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always upgrade small to medium first
|
||||||
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
return upgradeToMaxQuality(imageURL)
|
result = upgradeToMaxQuality(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return imageURL
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,31 +22,32 @@ const (
|
|||||||
|
|
||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
// Parallel ISRC fetching settings
|
deezerMaxParallelISRC = 10
|
||||||
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
|
||||||
|
// Deezer API timeout and retry configuration for mobile networks
|
||||||
|
deezerAPITimeoutMobile = 25 * time.Second
|
||||||
|
deezerMaxRetries = 2
|
||||||
|
deezerRetryDelay = 500 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeezerClient handles Deezer API interactions (no auth required)
|
|
||||||
type DeezerClient struct {
|
type DeezerClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
searchCache map[string]*cacheEntry
|
searchCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry
|
albumCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry
|
artistCache map[string]*cacheEntry
|
||||||
isrcCache map[string]string // trackID -> ISRC cache
|
isrcCache map[string]string
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
var (
|
var (
|
||||||
deezerClient *DeezerClient
|
deezerClient *DeezerClient
|
||||||
deezerClientOnce sync.Once
|
deezerClientOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetDeezerClient returns singleton Deezer client
|
|
||||||
func GetDeezerClient() *DeezerClient {
|
func GetDeezerClient() *DeezerClient {
|
||||||
deezerClientOnce.Do(func() {
|
deezerClientOnce.Do(func() {
|
||||||
deezerClient = &DeezerClient{
|
deezerClient = &DeezerClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
||||||
searchCache: make(map[string]*cacheEntry),
|
searchCache: make(map[string]*cacheEntry),
|
||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
artistCache: make(map[string]*cacheEntry),
|
artistCache: make(map[string]*cacheEntry),
|
||||||
@@ -56,16 +57,15 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
return deezerClient
|
return deezerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer API response types
|
|
||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Duration int `json:"duration"` // in seconds
|
Duration int `json:"duration"`
|
||||||
TrackPosition int `json:"track_position"`
|
TrackPosition int `json:"track_position"`
|
||||||
DiskNumber int `json:"disk_number"`
|
DiskNumber int `json:"disk_number"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
ReleaseDate string `json:"release_date"`
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Album deezerAlbumSimple `json:"album"`
|
Album deezerAlbumSimple `json:"album"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
@@ -88,12 +88,10 @@ type deezerAlbumSimple struct {
|
|||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
ReleaseDate string `json:"release_date"`
|
||||||
|
RecordType string `json:"record_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (skip other structs as they are fine/unchanged) ...
|
|
||||||
|
|
||||||
// ... (in convertTrack) ...
|
|
||||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
artistName := track.Artist.Name
|
artistName := track.Artist.Name
|
||||||
if len(track.Contributors) > 0 {
|
if len(track.Contributors) > 0 {
|
||||||
@@ -115,7 +113,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
albumImage = track.Album.Cover
|
albumImage = track.Album.Cover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find release date
|
|
||||||
releaseDate := track.ReleaseDate
|
releaseDate := track.ReleaseDate
|
||||||
if releaseDate == "" {
|
if releaseDate == "" {
|
||||||
releaseDate = track.Album.ReleaseDate
|
releaseDate = track.Album.ReleaseDate
|
||||||
@@ -129,7 +126,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
AlbumArtist: track.Artist.Name,
|
AlbumArtist: track.Artist.Name,
|
||||||
DurationMS: track.Duration * 1000,
|
DurationMS: track.Duration * 1000,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: releaseDate, // Added this
|
ReleaseDate: releaseDate,
|
||||||
TrackNumber: track.TrackPosition,
|
TrackNumber: track.TrackPosition,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
@@ -137,15 +134,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type deezerGenre struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type deezerAlbumFull struct {
|
type deezerAlbumFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
RecordType string `json:"record_type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Genres struct {
|
||||||
|
Data []deezerGenre `json:"data"`
|
||||||
|
} `json:"genres"`
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
@@ -180,12 +187,38 @@ type deezerPlaylistFull struct {
|
|||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAll searches for tracks and artists on Deezer
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
|
||||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
albumLimit := 5
|
||||||
|
playlistLimit := 5
|
||||||
|
|
||||||
|
if filter != "" {
|
||||||
|
switch filter {
|
||||||
|
case "track":
|
||||||
|
trackLimit = 50
|
||||||
|
artistLimit = 0
|
||||||
|
albumLimit = 0
|
||||||
|
playlistLimit = 0
|
||||||
|
case "artist":
|
||||||
|
trackLimit = 0
|
||||||
|
artistLimit = 20
|
||||||
|
albumLimit = 0
|
||||||
|
playlistLimit = 0
|
||||||
|
case "album":
|
||||||
|
trackLimit = 0
|
||||||
|
artistLimit = 0
|
||||||
|
albumLimit = 20
|
||||||
|
playlistLimit = 0
|
||||||
|
case "playlist":
|
||||||
|
trackLimit = 0
|
||||||
|
artistLimit = 0
|
||||||
|
albumLimit = 0
|
||||||
|
playlistLimit = 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d:%d:%s", query, trackLimit, artistLimit, albumLimit, playlistLimit, filter)
|
||||||
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
@@ -196,73 +229,190 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
result := &SearchAllResult{
|
result := &SearchAllResult{
|
||||||
Tracks: make([]TrackMetadata, 0),
|
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||||
Artists: make([]SearchArtistResult, 0),
|
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||||
|
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||||
|
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search tracks - NO ISRC fetch for performance
|
if trackLimit > 0 {
|
||||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||||
|
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
Error *struct {
|
Error *struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
GoLog("[Deezer] Track search failed: %v\n", err)
|
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if trackResp.Error != nil {
|
if trackResp.Error != nil {
|
||||||
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||||
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||||
|
|
||||||
for _, track := range trackResp.Data {
|
for _, track := range trackResp.Data {
|
||||||
// Convert directly without fetching ISRC - much faster
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search artists
|
|
||||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
|
||||||
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
|
||||||
|
|
||||||
var artistResp struct {
|
|
||||||
Data []deezerArtist `json:"data"`
|
|
||||||
Error *struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code int `json:"code"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
|
||||||
if artistResp.Error != nil {
|
|
||||||
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
|
||||||
for _, artist := range artistResp.Data {
|
|
||||||
result.Artists = append(result.Artists, SearchArtistResult{
|
|
||||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
|
||||||
Name: artist.Name,
|
|
||||||
Images: c.getBestArtistImage(artist),
|
|
||||||
Followers: artist.NbFan,
|
|
||||||
Popularity: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] Artist search failed: %v\n", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
if artistLimit > 0 {
|
||||||
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
|
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||||
|
|
||||||
|
var artistResp struct {
|
||||||
|
Data []deezerArtist `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
|
if artistResp.Error != nil {
|
||||||
|
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||||
|
for _, artist := range artistResp.Data {
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImage(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if albumLimit > 0 {
|
||||||
|
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
|
||||||
|
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
|
||||||
|
|
||||||
|
var albumResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
RecordType string `json:"record_type"`
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, albumURL, &albumResp); err == nil {
|
||||||
|
if albumResp.Error != nil {
|
||||||
|
GoLog("[Deezer] Album API error: type=%s, code=%d, message=%s\n", albumResp.Error.Type, albumResp.Error.Code, albumResp.Error.Message)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Got %d albums from API\n", len(albumResp.Data))
|
||||||
|
for _, album := range albumResp.Data {
|
||||||
|
coverURL := album.CoverXL
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverBig
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverMedium
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
albumType := album.RecordType
|
||||||
|
if albumType == "compile" {
|
||||||
|
albumType = "compilation"
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Albums = append(result.Albums, SearchAlbumResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
Name: album.Title,
|
||||||
|
Artists: album.Artist.Name,
|
||||||
|
Images: coverURL,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
AlbumType: albumType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Album search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if playlistLimit > 0 {
|
||||||
|
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
|
||||||
|
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
|
||||||
|
|
||||||
|
var playlistResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
User struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"user"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, playlistURL, &playlistResp); err == nil {
|
||||||
|
if playlistResp.Error != nil {
|
||||||
|
GoLog("[Deezer] Playlist API error: type=%s, code=%d, message=%s\n", playlistResp.Error.Type, playlistResp.Error.Code, playlistResp.Error.Message)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Got %d playlists from API\n", len(playlistResp.Data))
|
||||||
|
for _, playlist := range playlistResp.Data {
|
||||||
|
pictureURL := playlist.PictureXL
|
||||||
|
if pictureURL == "" {
|
||||||
|
pictureURL = playlist.PictureBig
|
||||||
|
}
|
||||||
|
if pictureURL == "" {
|
||||||
|
pictureURL = playlist.PictureMedium
|
||||||
|
}
|
||||||
|
if pictureURL == "" {
|
||||||
|
pictureURL = playlist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Playlists = append(result.Playlists, SearchPlaylistResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", playlist.ID),
|
||||||
|
Name: playlist.Title,
|
||||||
|
Owner: playlist.User.Name,
|
||||||
|
Images: pictureURL,
|
||||||
|
TotalTracks: playlist.NbTracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Playlist search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
||||||
|
|
||||||
// Cache result
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -273,7 +423,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrack fetches a single track by Deezer ID
|
|
||||||
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
@@ -287,8 +436,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlbum fetches album with tracks
|
|
||||||
// ISRC is fetched in parallel for better performance
|
|
||||||
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
@@ -314,22 +461,75 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
artistName = strings.Join(names, ", ")
|
artistName = strings.Join(names, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var genres []string
|
||||||
|
for _, g := range album.Genres.Data {
|
||||||
|
if g.Name != "" {
|
||||||
|
genres = append(genres, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
genreStr := strings.Join(genres, ", ")
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
Name: album.Title,
|
Name: album.Title,
|
||||||
ReleaseDate: album.ReleaseDate,
|
ReleaseDate: album.ReleaseDate,
|
||||||
Artists: artistName,
|
Artists: artistName,
|
||||||
|
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
|
Genre: genreStr,
|
||||||
|
Label: album.Label,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ISRCs in parallel
|
allTracks := album.Tracks.Data
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
if album.NbTracks > len(allTracks) {
|
||||||
for _, track := range album.Tracks.Data {
|
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
|
||||||
|
|
||||||
|
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
|
||||||
|
|
||||||
|
for len(allTracks) < album.NbTracks {
|
||||||
|
var tracksResp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
||||||
|
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tracksResp.Data) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allTracks = append(allTracks, tracksResp.Data...)
|
||||||
|
|
||||||
|
if tracksResp.Next == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracksURL = tracksResp.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
|
albumType := album.RecordType
|
||||||
|
if albumType == "compile" {
|
||||||
|
albumType = "compilation"
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, track := range allTracks {
|
||||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
isrc := isrcMap[trackIDStr]
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
|
trackNum := track.TrackPosition
|
||||||
|
if trackNum == 0 {
|
||||||
|
trackNum = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
Artists: track.Artist.Name,
|
Artists: track.Artist.Name,
|
||||||
@@ -339,12 +539,13 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
DurationMS: track.Duration * 1000,
|
DurationMS: track.Duration * 1000,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: album.ReleaseDate,
|
ReleaseDate: album.ReleaseDate,
|
||||||
TrackNumber: track.TrackPosition,
|
TrackNumber: trackNum,
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
AlbumType: albumType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +564,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArtist fetches artist with albums
|
|
||||||
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
@@ -372,7 +572,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Fetch artist info
|
|
||||||
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
||||||
var artist deezerArtistFull
|
var artist deezerArtistFull
|
||||||
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
||||||
@@ -387,7 +586,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
Popularity: 0,
|
Popularity: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch artist albums
|
|
||||||
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
||||||
var albumsResp struct {
|
var albumsResp struct {
|
||||||
Data []struct {
|
Data []struct {
|
||||||
@@ -399,7 +597,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
RecordType string `json:"record_type"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,8 +647,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPlaylist fetches playlist with tracks
|
|
||||||
// ISRC is fetched in parallel for better performance
|
|
||||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||||
|
|
||||||
@@ -473,11 +669,43 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
info.Owner.Name = playlist.Title
|
info.Owner.Name = playlist.Title
|
||||||
info.Owner.Images = playlistImage
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
// Fetch ISRCs in parallel
|
allTracks := playlist.Tracks.Data
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
if playlist.NbTracks > len(allTracks) {
|
||||||
for _, track := range playlist.Tracks.Data {
|
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
|
||||||
|
|
||||||
|
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
|
||||||
|
|
||||||
|
for len(allTracks) < playlist.NbTracks {
|
||||||
|
var tracksResp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
||||||
|
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tracksResp.Data) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allTracks = append(allTracks, tracksResp.Data...)
|
||||||
|
|
||||||
|
if tracksResp.Next == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracksURL = tracksResp.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
|
for _, track := range allTracks {
|
||||||
albumImage := track.Album.CoverXL
|
albumImage := track.Album.CoverXL
|
||||||
if albumImage == "" {
|
if albumImage == "" {
|
||||||
albumImage = track.Album.CoverBig
|
albumImage = track.Album.CoverBig
|
||||||
@@ -512,15 +740,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchByISRC searches for a track by ISRC using direct endpoint
|
|
||||||
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||||
// Use direct ISRC endpoint (API 2.0)
|
|
||||||
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
|
||||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||||
|
|
||||||
var track deezerTrack
|
var track deezerTrack
|
||||||
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||||
// Fallback to search if direct endpoint fails
|
|
||||||
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
|
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
@@ -535,7 +759,6 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we got a valid response (ID > 0)
|
|
||||||
if track.ID == 0 {
|
if track.ID == 0 {
|
||||||
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
}
|
}
|
||||||
@@ -553,16 +776,25 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
|||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
|
||||||
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string, len(tracks))
|
||||||
var resultMu sync.Mutex
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
// First, check cache for existing ISRCs
|
|
||||||
var tracksToFetch []deezerTrack
|
var tracksToFetch []deezerTrack
|
||||||
|
var directISRCs map[string]string
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
for _, track := range tracks {
|
for _, track := range tracks {
|
||||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
if track.ISRC != "" {
|
||||||
|
result[trackIDStr] = track.ISRC
|
||||||
|
if _, ok := c.isrcCache[trackIDStr]; !ok {
|
||||||
|
if directISRCs == nil {
|
||||||
|
directISRCs = make(map[string]string)
|
||||||
|
}
|
||||||
|
directISRCs[trackIDStr] = track.ISRC
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if isrc, ok := c.isrcCache[trackIDStr]; ok {
|
if isrc, ok := c.isrcCache[trackIDStr]; ok {
|
||||||
result[trackIDStr] = isrc
|
result[trackIDStr] = isrc
|
||||||
} else {
|
} else {
|
||||||
@@ -570,12 +802,18 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
if len(directISRCs) > 0 {
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
for trackIDStr, isrc := range directISRCs {
|
||||||
|
c.isrcCache[trackIDStr] = isrc
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
if len(tracksToFetch) == 0 {
|
if len(tracksToFetch) == 0 {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use semaphore to limit concurrent requests
|
|
||||||
sem := make(chan struct{}, deezerMaxParallelISRC)
|
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
@@ -584,7 +822,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
go func(t deezerTrack) {
|
go func(t deezerTrack) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
// Acquire semaphore
|
|
||||||
select {
|
select {
|
||||||
case sem <- struct{}{}:
|
case sem <- struct{}{}:
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
@@ -598,7 +835,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in result and cache
|
|
||||||
resultMu.Lock()
|
resultMu.Lock()
|
||||||
result[trackIDStr] = fullTrack.ISRC
|
result[trackIDStr] = fullTrack.ISRC
|
||||||
resultMu.Unlock()
|
resultMu.Unlock()
|
||||||
@@ -613,10 +849,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrackISRC fetches ISRC for a single track (with caching)
|
|
||||||
// Use this when you need ISRC for download
|
|
||||||
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if isrc, ok := c.isrcCache[trackID]; ok {
|
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -624,13 +857,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Fetch from API
|
|
||||||
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackID] = fullTrack.ISRC
|
c.isrcCache[trackID] = fullTrack.ISRC
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
@@ -677,7 +908,131 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
return album.Cover
|
return album.Cover
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlbumExtendedMetadata struct {
|
||||||
|
Genre string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||||
|
if albumID == "" {
|
||||||
|
return nil, fmt.Errorf("empty album ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("album_meta:%s", albumID)
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumExtendedMetadata), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||||
|
|
||||||
|
var album deezerAlbumFull
|
||||||
|
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var genres []string
|
||||||
|
for _, g := range album.Genres.Data {
|
||||||
|
if g.Name != "" {
|
||||||
|
genres = append(genres, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AlbumExtendedMetadata{
|
||||||
|
Genre: strings.Join(genres, ", "),
|
||||||
|
Label: album.Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
|
||||||
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d", track.Album.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
|
||||||
|
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get album ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.GetAlbumExtendedMetadata(ctx, albumID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||||
|
if isrc == "" {
|
||||||
|
return nil, fmt.Errorf("empty ISRC")
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := c.SearchByISRC(ctx, isrc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:")
|
||||||
|
|
||||||
|
if deezerID == "" {
|
||||||
|
return nil, fmt.Errorf("track found but no Deezer ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||||
|
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.doGetJSON(ctx, endpoint, dst)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
errStr := err.Error()
|
||||||
|
|
||||||
|
// Check if error is retryable
|
||||||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
strings.Contains(errStr, "EOF") ||
|
||||||
|
strings.Contains(errStr, "status 5") ||
|
||||||
|
strings.Contains(errStr, "status 429")
|
||||||
|
|
||||||
|
if !isRetryable {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -703,7 +1058,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
return json.Unmarshal(body, dst)
|
return json.Unmarshal(body, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDeezerURL is internal function, returns type and ID
|
|
||||||
func parseDeezerURL(input string) (string, string, error) {
|
func parseDeezerURL(input string) (string, string, error) {
|
||||||
trimmed := strings.TrimSpace(input)
|
trimmed := strings.TrimSpace(input)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -721,7 +1075,6 @@ func parseDeezerURL(input string) (string, string, error) {
|
|||||||
|
|
||||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
|
||||||
// Skip language prefix if present (e.g., /en/, /fr/)
|
|
||||||
if len(parts) > 0 && len(parts[0]) == 2 {
|
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||||
parts = parts[1:]
|
parts = parts[1:]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
|
|
||||||
type ISRCIndex struct {
|
type ISRCIndex struct {
|
||||||
index map[string]string // ISRC (uppercase) -> file path
|
index map[string]string // ISRC (uppercase) -> file path
|
||||||
outputDir string
|
outputDir string
|
||||||
@@ -18,30 +17,42 @@ type ISRCIndex struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global ISRC index cache (per output directory)
|
|
||||||
var (
|
var (
|
||||||
isrcIndexCache = make(map[string]*ISRCIndex)
|
isrcIndexCache = make(map[string]*ISRCIndex)
|
||||||
isrcIndexCacheMu sync.RWMutex
|
isrcIndexCacheMu sync.RWMutex
|
||||||
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
|
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
|
||||||
|
isrcIndexTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetISRCIndex returns or builds an ISRC index for the given directory
|
|
||||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
|
// Fast path: check cache first
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
idx, exists := isrcIndexCache[outputDir]
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
isrcIndexCacheMu.RUnlock()
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
// Return cached index if still valid
|
|
||||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build new index
|
// Slow path: need to build index
|
||||||
|
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||||
|
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||||
|
mu := buildLock.(*sync.Mutex)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check cache after acquiring lock (another goroutine may have built it)
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists = isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
return buildISRCIndex(outputDir)
|
return buildISRCIndex(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
|
||||||
// Same implementation as PC version for consistency
|
|
||||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
idx := &ISRCIndex{
|
idx := &ISRCIndex{
|
||||||
index: make(map[string]string),
|
index: make(map[string]string),
|
||||||
@@ -56,7 +67,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
fileCount := 0
|
fileCount := 0
|
||||||
|
|
||||||
// Walk directory - only check .flac files
|
|
||||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
@@ -67,22 +77,19 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read ISRC from file
|
|
||||||
metadata, err := ReadMetadata(path)
|
metadata, err := ReadMetadata(path)
|
||||||
if err != nil || metadata.ISRC == "" {
|
if err != nil || metadata.ISRC == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in index (uppercase for case-insensitive matching)
|
|
||||||
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||||
fileCount++
|
fileCount++
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
// Cache the index
|
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
isrcIndexCache[outputDir] = idx
|
isrcIndexCache[outputDir] = idx
|
||||||
isrcIndexCacheMu.Unlock()
|
isrcIndexCacheMu.Unlock()
|
||||||
@@ -90,7 +97,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookup checks if an ISRC exists in the index (internal, returns bool)
|
|
||||||
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -103,14 +109,22 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
|||||||
return path, exists
|
return path, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
func (idx *ISRCIndex) remove(isrc string) {
|
||||||
// Returns filepath if found, empty string if not found
|
if isrc == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.mu.Lock()
|
||||||
|
defer idx.mu.Unlock()
|
||||||
|
|
||||||
|
delete(idx.index, strings.ToUpper(isrc))
|
||||||
|
}
|
||||||
|
|
||||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
path, _ := idx.lookup(isrc)
|
path, _ := idx.lookup(isrc)
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds a new ISRC to the index (call after successful download)
|
|
||||||
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||||
if isrc == "" || filePath == "" {
|
if isrc == "" || filePath == "" {
|
||||||
return
|
return
|
||||||
@@ -122,33 +136,37 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
|
|||||||
idx.index[strings.ToUpper(isrc)] = filePath
|
idx.index[strings.ToUpper(isrc)] = filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateCache clears the ISRC index cache for a directory
|
|
||||||
func InvalidateISRCCache(outputDir string) {
|
func InvalidateISRCCache(outputDir string) {
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
delete(isrcIndexCache, outputDir)
|
delete(isrcIndexCache, outputDir)
|
||||||
isrcIndexCacheMu.Unlock()
|
isrcIndexCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
|
||||||
// Uses ISRC index for fast lookup
|
|
||||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||||
if isrc == "" || outputDir == "" {
|
if isrc == "" || outputDir == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use index for fast lookup
|
|
||||||
idx := GetISRCIndex(outputDir)
|
idx := GetISRCIndex(outputDir)
|
||||||
return idx.lookup(isrc)
|
filePath, exists := idx.lookup(isrc)
|
||||||
|
if !exists {
|
||||||
|
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) {
|
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||||
return filepath, nil
|
return filepath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckFileExists checks if a file with the given name exists
|
|
||||||
func CheckFileExists(filePath string) bool {
|
func CheckFileExists(filePath string) bool {
|
||||||
info, err := os.Stat(filePath)
|
info, err := os.Stat(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,7 +175,6 @@ func CheckFileExists(filePath string) bool {
|
|||||||
return !info.IsDir() && info.Size() > 0
|
return !info.IsDir() && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileExistenceResult represents the result of checking if a file exists
|
|
||||||
type FileExistenceResult struct {
|
type FileExistenceResult struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
@@ -166,11 +183,7 @@ type FileExistenceResult struct {
|
|||||||
ArtistName string `json:"artist_name,omitempty"`
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckFilesExistParallel checks if multiple files exist in parallel
|
|
||||||
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
|
||||||
// Same implementation as PC version for consistency
|
|
||||||
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||||
// Parse input JSON
|
|
||||||
var tracks []struct {
|
var tracks []struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
TrackName string `json:"track_name"`
|
TrackName string `json:"track_name"`
|
||||||
@@ -182,10 +195,8 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
|
|
||||||
results := make([]FileExistenceResult, len(tracks))
|
results := make([]FileExistenceResult, len(tracks))
|
||||||
|
|
||||||
// Build ISRC index from output directory (scan once)
|
|
||||||
isrcIdx := GetISRCIndex(outputDir)
|
isrcIdx := GetISRCIndex(outputDir)
|
||||||
|
|
||||||
// Check each track against the index (parallel)
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -216,7 +227,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Return results as JSON
|
|
||||||
resultJSON, err := json.Marshal(results)
|
resultJSON, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to marshal results: %w", err)
|
return "", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
@@ -225,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
return string(resultJSON), nil
|
return string(resultJSON), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreBuildISRCIndex pre-builds the ISRC index for a directory
|
|
||||||
// Call this when app starts or when entering album/playlist screen
|
|
||||||
func PreBuildISRCIndex(outputDir string) error {
|
func PreBuildISRCIndex(outputDir string) error {
|
||||||
if outputDir == "" {
|
if outputDir == "" {
|
||||||
return fmt.Errorf("output directory is required")
|
return fmt.Errorf("output directory is required")
|
||||||
@@ -236,8 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
|
||||||
// This avoids rebuilding the entire index
|
|
||||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||||
if outputDir == "" || isrc == "" || filePath == "" {
|
if outputDir == "" || isrc == "" || filePath == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,970 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func compareVersions(v1, v2 string) int {
|
||||||
|
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||||
|
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||||
|
|
||||||
|
maxLen := len(parts1)
|
||||||
|
if len(parts2) > maxLen {
|
||||||
|
maxLen = len(parts2)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < maxLen; i++ {
|
||||||
|
var n1, n2 int
|
||||||
|
if i < len(parts1) {
|
||||||
|
n1, _ = strconv.Atoi(parts1[i])
|
||||||
|
}
|
||||||
|
if i < len(parts2) {
|
||||||
|
n2, _ = strconv.Atoi(parts2[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
if n1 < n2 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if n1 > n2 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadedExtension struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
|
VM *goja.Runtime `json:"-"`
|
||||||
|
VMMu sync.Mutex `json:"-"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
SourceDir string `json:"source_dir"`
|
||||||
|
IconPath string `json:"icon_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
extensions map[string]*LoadedExtension
|
||||||
|
extensionsDir string
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalExtManager *ExtensionManager
|
||||||
|
globalExtManagerOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetExtensionManager() *ExtensionManager {
|
||||||
|
globalExtManagerOnce.Do(func() {
|
||||||
|
globalExtManager = &ExtensionManager{
|
||||||
|
extensions: make(map[string]*LoadedExtension),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalExtManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.extensionsDir = extensionsDir
|
||||||
|
m.dataDir = dataDir
|
||||||
|
|
||||||
|
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create extensions directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
||||||
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
|
}
|
||||||
|
|
||||||
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||||
|
}
|
||||||
|
defer zipReader.Close()
|
||||||
|
|
||||||
|
var manifestData []byte
|
||||||
|
var hasIndexJS bool
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
name := filepath.Base(file.Name)
|
||||||
|
if name == "manifest.json" {
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
manifestData, err = io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "index.js" {
|
||||||
|
hasIndexJS = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifestData == nil {
|
||||||
|
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasIndexJS {
|
||||||
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := ParseManifest(manifestData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
existing, exists := m.extensions[manifest.Name]
|
||||||
|
var existingVersion string
|
||||||
|
var existingDisplayName string
|
||||||
|
if exists {
|
||||||
|
existingVersion = existing.Manifest.Version
|
||||||
|
existingDisplayName = existing.Manifest.DisplayName
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
|
if versionCompare > 0 {
|
||||||
|
// This is an upgrade - call UpgradeExtension
|
||||||
|
return m.UpgradeExtension(filePath)
|
||||||
|
} else if versionCompare == 0 {
|
||||||
|
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("Cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := m.extensions[manifest.Name]; exists {
|
||||||
|
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||||
|
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := filepath.Clean(file.Name)
|
||||||
|
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||||
|
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
destPath := filepath.Join(extDir, relPath)
|
||||||
|
|
||||||
|
destDir := filepath.Dir(destPath)
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destFile, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcFile, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
destFile.Close()
|
||||||
|
return nil, fmt.Errorf("failed to open file in archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(destFile, srcFile)
|
||||||
|
srcFile.Close()
|
||||||
|
destFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to extract file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||||
|
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: manifest.Name,
|
||||||
|
Manifest: manifest,
|
||||||
|
Enabled: false, // New extensions start disabled
|
||||||
|
DataDir: extDataDir,
|
||||||
|
SourceDir: extDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.initializeVM(ext); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.extensions[manifest.Name] = ext
|
||||||
|
GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version)
|
||||||
|
|
||||||
|
return ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||||
|
vm := goja.New()
|
||||||
|
ext.VM = vm
|
||||||
|
|
||||||
|
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||||
|
jsCode, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
runtime.RegisterAPIs(vm)
|
||||||
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
|
|
||||||
|
console := vm.NewObject()
|
||||||
|
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||||
|
args := make([]interface{}, len(call.Arguments))
|
||||||
|
for i, arg := range call.Arguments {
|
||||||
|
args[i] = arg.Export()
|
||||||
|
}
|
||||||
|
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
vm.Set("console", console)
|
||||||
|
|
||||||
|
var registeredExtension goja.Value
|
||||||
|
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) > 0 {
|
||||||
|
registeredExtension = call.Arguments[0]
|
||||||
|
vm.Set("extension", call.Arguments[0])
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = vm.RunString(string(jsCode))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||||
|
return fmt.Errorf("extension did not call registerExtension()")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
ext, exists := m.extensions[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("Extension not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext.VM != nil {
|
||||||
|
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||||
|
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
|
||||||
|
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(m.extensions, extensionID)
|
||||||
|
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
ext, exists := m.extensions[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("Extension not found")
|
||||||
|
}
|
||||||
|
return ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*LoadedExtension, 0, len(m.extensions))
|
||||||
|
for _, ext := range m.extensions {
|
||||||
|
result = append(result, ext)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
ext, exists := m.extensions[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("Extension not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.Enabled = enabled
|
||||||
|
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||||
|
|
||||||
|
store := GetExtensionSettingsStore()
|
||||||
|
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
|
||||||
|
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||||
|
var loaded []string
|
||||||
|
var errors []error
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dirPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return loaded, errors
|
||||||
|
}
|
||||||
|
return nil, []error{fmt.Errorf("failed to read extensions directory: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
|
||||||
|
if _, err := os.Stat(manifestPath); err == nil {
|
||||||
|
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||||
|
errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err))
|
||||||
|
} else {
|
||||||
|
loaded = append(loaded, ext.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||||
|
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||||
|
errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err))
|
||||||
|
} else {
|
||||||
|
loaded = append(loaded, ext.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return loaded, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
manifestPath := filepath.Join(dirPath, "manifest.json")
|
||||||
|
manifestData, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate manifest
|
||||||
|
manifest, err := ParseManifest(manifestData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexPath := filepath.Join(dirPath, "index.js")
|
||||||
|
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("Extension is missing index.js file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||||
|
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||||
|
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: manifest.Name,
|
||||||
|
Manifest: manifest,
|
||||||
|
Enabled: false, // Will be restored from settings store
|
||||||
|
DataDir: extDataDir,
|
||||||
|
SourceDir: dirPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore enabled state from settings store
|
||||||
|
store := GetExtensionSettingsStore()
|
||||||
|
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
||||||
|
if enabled, ok := enabledVal.(bool); ok {
|
||||||
|
ext.Enabled = enabled
|
||||||
|
GoLog("[Extension] Restored enabled state for %s: %v\n", manifest.Name, enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.initializeVM(ext); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.extensions[manifest.Name] = ext
|
||||||
|
GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version)
|
||||||
|
|
||||||
|
return ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||||
|
ext, err := m.GetExtension(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.UnloadExtension(extensionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext.SourceDir != "" {
|
||||||
|
if err := os.RemoveAll(ext.SourceDir); err != nil {
|
||||||
|
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally remove data directory (keep for now to preserve settings)
|
||||||
|
// if ext.DataDir != "" {
|
||||||
|
// os.RemoveAll(ext.DataDir)
|
||||||
|
// }
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
|
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||||
|
// Validate file extension
|
||||||
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
|
}
|
||||||
|
|
||||||
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||||
|
}
|
||||||
|
defer zipReader.Close()
|
||||||
|
|
||||||
|
var manifestData []byte
|
||||||
|
var hasIndexJS bool
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
name := filepath.Base(file.Name)
|
||||||
|
if name == "manifest.json" {
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
manifestData, err = io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "index.js" {
|
||||||
|
hasIndexJS = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifestData == nil {
|
||||||
|
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasIndexJS {
|
||||||
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
newManifest, err := ParseManifest(manifestData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
existing, exists := m.extensions[newManifest.Name]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare versions - only allow upgrade, not downgrade
|
||||||
|
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
||||||
|
if versionCompare < 0 {
|
||||||
|
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
||||||
|
}
|
||||||
|
if versionCompare == 0 {
|
||||||
|
return nil, fmt.Errorf("Extension is already at version %s", existing.Manifest.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
||||||
|
|
||||||
|
// Save data directory path and enabled state (we want to preserve them)
|
||||||
|
extDataDir := existing.DataDir
|
||||||
|
extDir := existing.SourceDir
|
||||||
|
wasEnabled := existing.Enabled
|
||||||
|
|
||||||
|
m.CleanupExtension(existing.ID)
|
||||||
|
m.UnloadExtension(existing.ID)
|
||||||
|
|
||||||
|
if extDir != "" {
|
||||||
|
if err := os.RemoveAll(extDir); err != nil {
|
||||||
|
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := filepath.Clean(file.Name)
|
||||||
|
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||||
|
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
destPath := filepath.Join(extDir, relPath)
|
||||||
|
|
||||||
|
destDir := filepath.Dir(destPath)
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destFile, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcFile, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
destFile.Close()
|
||||||
|
return nil, fmt.Errorf("failed to open file in archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(destFile, srcFile)
|
||||||
|
srcFile.Close()
|
||||||
|
destFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to extract file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: newManifest.Name,
|
||||||
|
Manifest: newManifest,
|
||||||
|
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||||
|
DataDir: extDataDir,
|
||||||
|
SourceDir: extDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Goja VM
|
||||||
|
if err := m.initializeVM(ext); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.extensions[newManifest.Name] = ext
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension] Upgraded extension: %s to v%s\n", newManifest.DisplayName, newManifest.Version)
|
||||||
|
|
||||||
|
return ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionUpgradeInfo struct {
|
||||||
|
ExtensionID string `json:"extension_id"`
|
||||||
|
CurrentVersion string `json:"current_version"`
|
||||||
|
NewVersion string `json:"new_version"`
|
||||||
|
CanUpgrade bool `json:"can_upgrade"`
|
||||||
|
IsInstalled bool `json:"is_installed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
|
// Validate file extension
|
||||||
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
|
}
|
||||||
|
|
||||||
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Cannot open extension file")
|
||||||
|
}
|
||||||
|
defer zipReader.Close()
|
||||||
|
|
||||||
|
var manifestData []byte
|
||||||
|
for _, file := range zipReader.File {
|
||||||
|
name := filepath.Base(file.Name)
|
||||||
|
if name == "manifest.json" {
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open manifest.json")
|
||||||
|
}
|
||||||
|
manifestData, err = io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read manifest.json")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifestData == nil {
|
||||||
|
return nil, fmt.Errorf("manifest.json not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
newManifest, err := ParseManifest(manifestData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
existing, exists := m.extensions[newManifest.Name]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
info := &ExtensionUpgradeInfo{
|
||||||
|
ExtensionID: newManifest.Name,
|
||||||
|
NewVersion: newManifest.Version,
|
||||||
|
IsInstalled: exists,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
// Not installed - this is a new install, not upgrade
|
||||||
|
info.CurrentVersion = ""
|
||||||
|
info.CanUpgrade = false
|
||||||
|
} else {
|
||||||
|
info.CurrentVersion = existing.Manifest.Version
|
||||||
|
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||||
|
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||||
|
extensions := m.GetAllExtensions()
|
||||||
|
|
||||||
|
type ExtensionInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
IconPath string `json:"icon_path,omitempty"`
|
||||||
|
Types []ExtensionType `json:"types"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error_message,omitempty"`
|
||||||
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
|
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||||
|
HasDownloadProvider bool `json:"has_download_provider"`
|
||||||
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||||
|
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||||
|
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||||
|
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
infos := make([]ExtensionInfo, len(extensions))
|
||||||
|
for i, ext := range extensions {
|
||||||
|
permissions := []string{}
|
||||||
|
for _, domain := range ext.Manifest.Permissions.Network {
|
||||||
|
permissions = append(permissions, "network:"+domain)
|
||||||
|
}
|
||||||
|
if ext.Manifest.Permissions.Storage {
|
||||||
|
permissions = append(permissions, "storage:enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status
|
||||||
|
status := "loaded"
|
||||||
|
if ext.Error != "" {
|
||||||
|
status = "error"
|
||||||
|
} else if !ext.Enabled {
|
||||||
|
status = "disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
iconPath := ""
|
||||||
|
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
||||||
|
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
||||||
|
if _, err := os.Stat(possibleIcon); err == nil {
|
||||||
|
iconPath = possibleIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if iconPath == "" && ext.SourceDir != "" {
|
||||||
|
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
||||||
|
if _, err := os.Stat(possibleIcon); err == nil {
|
||||||
|
iconPath = possibleIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
infos[i] = ExtensionInfo{
|
||||||
|
ID: ext.ID,
|
||||||
|
Name: ext.Manifest.Name,
|
||||||
|
DisplayName: ext.Manifest.DisplayName,
|
||||||
|
Version: ext.Manifest.Version,
|
||||||
|
Author: ext.Manifest.Author,
|
||||||
|
Description: ext.Manifest.Description,
|
||||||
|
Homepage: ext.Manifest.Homepage,
|
||||||
|
IconPath: iconPath,
|
||||||
|
Types: ext.Manifest.Types,
|
||||||
|
Enabled: ext.Enabled,
|
||||||
|
Status: status,
|
||||||
|
Error: ext.Error,
|
||||||
|
Settings: ext.Manifest.Settings,
|
||||||
|
QualityOptions: ext.Manifest.QualityOptions,
|
||||||
|
Permissions: permissions,
|
||||||
|
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
|
||||||
|
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||||
|
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||||
|
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||||
|
TrackMatching: ext.Manifest.TrackMatching,
|
||||||
|
PostProcessing: ext.Manifest.PostProcessing,
|
||||||
|
Capabilities: ext.Manifest.Capabilities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(infos)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
ext, exists := m.extensions[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("Extension not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext.VM == nil {
|
||||||
|
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsJSON, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to save settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
var settings = %s;
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||||
|
try {
|
||||||
|
extension.initialize(settings);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no initialize function' };
|
||||||
|
})()
|
||||||
|
`, string(settingsJSON))
|
||||||
|
|
||||||
|
result, err := ext.VM.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
ext.Error = errMsg
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||||
|
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Extension] Initialized %s\n", extensionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
ext, exists := m.extensions[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("Extension not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext.VM == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
script := `
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||||
|
try {
|
||||||
|
extension.cleanup();
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no cleanup function' };
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := ext.VM.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
|
||||||
|
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) UnloadAllExtensions() {
|
||||||
|
m.mu.Lock()
|
||||||
|
extensionIDs := make([]string, 0, len(m.extensions))
|
||||||
|
for id := range m.extensions {
|
||||||
|
extensionIDs = append(extensionIDs, id)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, id := range extensionIDs {
|
||||||
|
m.CleanupExtension(id)
|
||||||
|
m.UnloadExtension(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Extension] All extensions unloaded\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
ext, exists := m.extensions[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext.VM == nil {
|
||||||
|
return nil, fmt.Errorf("extension VM not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ext.Enabled {
|
||||||
|
return nil, fmt.Errorf("extension is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the action function on the extension object
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
|
try {
|
||||||
|
var result = extension.%s();
|
||||||
|
if (result && typeof result.then === 'function') {
|
||||||
|
// Handle promise - return pending status
|
||||||
|
return { success: true, pending: true, message: 'Action started' };
|
||||||
|
}
|
||||||
|
return { success: true, result: result };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Action function not found: %s' };
|
||||||
|
})()
|
||||||
|
`, actionName, actionName, actionName)
|
||||||
|
|
||||||
|
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||||
|
return nil, fmt.Errorf("action failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil || goja.IsUndefined(result) {
|
||||||
|
return map[string]interface{}{"success": true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
|
||||||
|
return resultMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{"success": true, "result": exported}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Package gobackend provides extension manifest parsing and validation
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtensionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||||
|
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingTypeString SettingType = "string"
|
||||||
|
SettingTypeNumber SettingType = "number"
|
||||||
|
SettingTypeBool SettingType = "boolean"
|
||||||
|
SettingTypeSelect SettingType = "select"
|
||||||
|
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtensionPermissions struct {
|
||||||
|
Network []string `json:"network"`
|
||||||
|
Storage bool `json:"storage"`
|
||||||
|
File bool `json:"file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionSetting struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type SettingType `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Secret bool `json:"secret,omitempty"`
|
||||||
|
Default interface{} `json:"default,omitempty"`
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QualityOption struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QualitySpecificSetting struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type SettingType `json:"type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Secret bool `json:"secret,omitempty"`
|
||||||
|
Default interface{} `json:"default,omitempty"`
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchFilter struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchBehaviorConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
|
Primary bool `json:"primary,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
|
||||||
|
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
|
||||||
|
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
|
||||||
|
Filters []SearchFilter `json:"filters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type URLHandlerConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Patterns []string `json:"patterns,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackMatchingConfig struct {
|
||||||
|
CustomMatching bool `json:"customMatching"`
|
||||||
|
Strategy string `json:"strategy,omitempty"`
|
||||||
|
DurationTolerance int `json:"durationTolerance,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostProcessingHook struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
|
||||||
|
SupportedFormats []string `json:"supportedFormats,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostProcessingConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionManifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Types []ExtensionType `json:"type"`
|
||||||
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||||
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManifestValidationError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ManifestValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||||
|
var manifest ExtensionManifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := manifest.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) Validate() error {
|
||||||
|
if strings.TrimSpace(m.Name) == "" {
|
||||||
|
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(m.Version) == "" {
|
||||||
|
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(m.Author) == "" {
|
||||||
|
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(m.Description) == "" {
|
||||||
|
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.Types) == 0 {
|
||||||
|
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range m.Types {
|
||||||
|
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: "type",
|
||||||
|
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, setting := range m.Settings {
|
||||||
|
if strings.TrimSpace(setting.Key) == "" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].key", i),
|
||||||
|
Message: "setting key is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if setting.Type == "" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].type", i),
|
||||||
|
Message: "setting type is required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select type requires options
|
||||||
|
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].options", i),
|
||||||
|
Message: "select type requires options",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if setting.Type == SettingTypeButton && setting.Action == "" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].action", i),
|
||||||
|
Message: "button type requires action (JS function name)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||||
|
for _, et := range m.Types {
|
||||||
|
if et == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||||
|
return m.HasType(ExtensionTypeMetadataProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||||
|
return m.HasType(ExtensionTypeDownloadProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||||
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
for _, allowed := range m.Permissions.Network {
|
||||||
|
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||||
|
if allowed == domain {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Support wildcard subdomains (e.g., *.example.com)
|
||||||
|
if strings.HasPrefix(allowed, "*.") {
|
||||||
|
suffix := allowed[1:]
|
||||||
|
if strings.HasSuffix(domain, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||||
|
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||||
|
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||||
|
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||||
|
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||||
|
if !m.HasURLHandler() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||||
|
for _, pattern := range m.URLHandler.Patterns {
|
||||||
|
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||||
|
if strings.Contains(urlStr, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||||
|
if m.PostProcessing == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.PostProcessing.Hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultJSTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
var (
|
||||||
|
extensionAuthState = make(map[string]*ExtensionAuthState)
|
||||||
|
extensionAuthStateMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtensionAuthState struct {
|
||||||
|
PendingAuthURL string
|
||||||
|
AuthCode string
|
||||||
|
AccessToken string
|
||||||
|
RefreshToken string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
IsAuthenticated bool
|
||||||
|
PKCEVerifier string
|
||||||
|
PKCEChallenge string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingAuthRequest struct {
|
||||||
|
ExtensionID string
|
||||||
|
AuthURL string
|
||||||
|
CallbackURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
||||||
|
pendingAuthRequestsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||||
|
pendingAuthRequestsMu.RLock()
|
||||||
|
defer pendingAuthRequestsMu.RUnlock()
|
||||||
|
return pendingAuthRequests[extensionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearPendingAuthRequest(extensionID string) {
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
defer pendingAuthRequestsMu.Unlock()
|
||||||
|
delete(pendingAuthRequests, extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
defer extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[extensionID] = state
|
||||||
|
}
|
||||||
|
state.AuthCode = authCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
defer extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[extensionID] = state
|
||||||
|
}
|
||||||
|
state.AccessToken = accessToken
|
||||||
|
state.RefreshToken = refreshToken
|
||||||
|
state.ExpiresAt = expiresAt
|
||||||
|
state.IsAuthenticated = accessToken != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionRuntime struct {
|
||||||
|
extensionID string
|
||||||
|
manifest *ExtensionManifest
|
||||||
|
settings map[string]interface{}
|
||||||
|
httpClient *http.Client
|
||||||
|
cookieJar http.CookieJar
|
||||||
|
dataDir string
|
||||||
|
vm *goja.Runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||||
|
jar, _ := newSimpleCookieJar()
|
||||||
|
|
||||||
|
runtime := &ExtensionRuntime{
|
||||||
|
extensionID: ext.ID,
|
||||||
|
manifest: ext.Manifest,
|
||||||
|
settings: make(map[string]interface{}),
|
||||||
|
cookieJar: jar,
|
||||||
|
dataDir: ext.DataDir,
|
||||||
|
vm: ext.VM,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Jar: jar,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if req.URL.Scheme != "https" {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||||
|
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := req.URL.Hostname()
|
||||||
|
if domain == "" {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
|
||||||
|
return fmt.Errorf("redirect blocked: hostname is required")
|
||||||
|
}
|
||||||
|
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||||
|
return &RedirectBlockedError{Domain: domain}
|
||||||
|
}
|
||||||
|
if isPrivateIP(domain) {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||||
|
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||||
|
}
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
runtime.httpClient = client
|
||||||
|
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedirectBlockedError struct {
|
||||||
|
Domain string
|
||||||
|
IsPrivate bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RedirectBlockedError) Error() string {
|
||||||
|
if e.IsPrivate {
|
||||||
|
return "redirect blocked: private/local network access denied"
|
||||||
|
}
|
||||||
|
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateIP checks if a hostname resolves to a private/local IP address
|
||||||
|
func isPrivateIP(host string) bool {
|
||||||
|
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||||
|
if hostLower == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip := net.ParseIP(hostLower); ip != nil {
|
||||||
|
return isPrivateIPAddr(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := net.LookupIP(hostLower)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ip := range ips {
|
||||||
|
if isPrivateIPAddr(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrivateIPAddr(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ip.IsLoopback() ||
|
||||||
|
ip.IsPrivate() ||
|
||||||
|
ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsLinkLocalMulticast() ||
|
||||||
|
ip.IsMulticast() ||
|
||||||
|
ip.IsUnspecified() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !ip.IsGlobalUnicast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type simpleCookieJar struct {
|
||||||
|
cookies map[string][]*http.Cookie
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSimpleCookieJar() (*simpleCookieJar, error) {
|
||||||
|
return &simpleCookieJar{
|
||||||
|
cookies: make(map[string][]*http.Cookie),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
key := u.Host
|
||||||
|
j.cookies[key] = append(j.cookies[key], cookies...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||||
|
j.mu.RLock()
|
||||||
|
defer j.mu.RUnlock()
|
||||||
|
return j.cookies[u.Host]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||||
|
r.settings = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||||
|
r.vm = vm
|
||||||
|
|
||||||
|
httpObj := vm.NewObject()
|
||||||
|
httpObj.Set("get", r.httpGet)
|
||||||
|
httpObj.Set("post", r.httpPost)
|
||||||
|
httpObj.Set("put", r.httpPut)
|
||||||
|
httpObj.Set("delete", r.httpDelete)
|
||||||
|
httpObj.Set("patch", r.httpPatch)
|
||||||
|
httpObj.Set("request", r.httpRequest)
|
||||||
|
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||||
|
vm.Set("http", httpObj)
|
||||||
|
|
||||||
|
storageObj := vm.NewObject()
|
||||||
|
storageObj.Set("get", r.storageGet)
|
||||||
|
storageObj.Set("set", r.storageSet)
|
||||||
|
storageObj.Set("remove", r.storageRemove)
|
||||||
|
vm.Set("storage", storageObj)
|
||||||
|
|
||||||
|
credentialsObj := vm.NewObject()
|
||||||
|
credentialsObj.Set("store", r.credentialsStore)
|
||||||
|
credentialsObj.Set("get", r.credentialsGet)
|
||||||
|
credentialsObj.Set("remove", r.credentialsRemove)
|
||||||
|
credentialsObj.Set("has", r.credentialsHas)
|
||||||
|
vm.Set("credentials", credentialsObj)
|
||||||
|
|
||||||
|
authObj := vm.NewObject()
|
||||||
|
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||||
|
authObj.Set("getAuthCode", r.authGetCode)
|
||||||
|
authObj.Set("setAuthCode", r.authSetCode)
|
||||||
|
authObj.Set("clearAuth", r.authClear)
|
||||||
|
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||||
|
authObj.Set("getTokens", r.authGetTokens)
|
||||||
|
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||||
|
authObj.Set("getPKCE", r.authGetPKCE)
|
||||||
|
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||||
|
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||||
|
vm.Set("auth", authObj)
|
||||||
|
|
||||||
|
fileObj := vm.NewObject()
|
||||||
|
fileObj.Set("download", r.fileDownload)
|
||||||
|
fileObj.Set("exists", r.fileExists)
|
||||||
|
fileObj.Set("delete", r.fileDelete)
|
||||||
|
fileObj.Set("read", r.fileRead)
|
||||||
|
fileObj.Set("write", r.fileWrite)
|
||||||
|
fileObj.Set("copy", r.fileCopy)
|
||||||
|
fileObj.Set("move", r.fileMove)
|
||||||
|
fileObj.Set("getSize", r.fileGetSize)
|
||||||
|
vm.Set("file", fileObj)
|
||||||
|
|
||||||
|
ffmpegObj := vm.NewObject()
|
||||||
|
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||||
|
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||||
|
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||||
|
vm.Set("ffmpeg", ffmpegObj)
|
||||||
|
|
||||||
|
matchingObj := vm.NewObject()
|
||||||
|
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||||
|
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||||
|
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
||||||
|
vm.Set("matching", matchingObj)
|
||||||
|
|
||||||
|
utilsObj := vm.NewObject()
|
||||||
|
utilsObj.Set("base64Encode", r.base64Encode)
|
||||||
|
utilsObj.Set("base64Decode", r.base64Decode)
|
||||||
|
utilsObj.Set("md5", r.md5Hash)
|
||||||
|
utilsObj.Set("sha256", r.sha256Hash)
|
||||||
|
utilsObj.Set("hmacSHA256", r.hmacSHA256)
|
||||||
|
utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64)
|
||||||
|
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
||||||
|
utilsObj.Set("parseJSON", r.parseJSON)
|
||||||
|
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||||
|
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||||
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||||
|
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||||
|
vm.Set("utils", utilsObj)
|
||||||
|
|
||||||
|
logObj := vm.NewObject()
|
||||||
|
logObj.Set("debug", r.logDebug)
|
||||||
|
logObj.Set("info", r.logInfo)
|
||||||
|
logObj.Set("warn", r.logWarn)
|
||||||
|
logObj.Set("error", r.logError)
|
||||||
|
vm.Set("log", logObj)
|
||||||
|
|
||||||
|
gobackendObj := vm.NewObject()
|
||||||
|
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||||
|
vm.Set("gobackend", gobackendObj)
|
||||||
|
|
||||||
|
vm.Set("fetch", r.fetchPolyfill)
|
||||||
|
|
||||||
|
vm.Set("atob", r.atobPolyfill)
|
||||||
|
vm.Set("btoa", r.btoaPolyfill)
|
||||||
|
|
||||||
|
r.registerTextEncoderDecoder(vm)
|
||||||
|
|
||||||
|
r.registerURLClass(vm)
|
||||||
|
|
||||||
|
r.registerJSONGlobal(vm)
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
// Package gobackend provides Auth API and PKCE support for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Auth API (OAuth Support) ====================
|
||||||
|
|
||||||
|
func (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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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, authURL)
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Auth URL will be opened by the app",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists || state.AuthCode == "" {
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(state.AuthCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
arg := call.Arguments[0].Export()
|
||||||
|
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
defer extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[r.extensionID] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := arg.(type) {
|
||||||
|
case string:
|
||||||
|
state.AuthCode = v
|
||||||
|
case map[string]interface{}:
|
||||||
|
if code, ok := v["code"].(string); ok {
|
||||||
|
state.AuthCode = code
|
||||||
|
}
|
||||||
|
if accessToken, ok := v["access_token"].(string); ok {
|
||||||
|
state.AccessToken = accessToken
|
||||||
|
state.IsAuthenticated = true
|
||||||
|
}
|
||||||
|
if refreshToken, ok := v["refresh_token"].(string); ok {
|
||||||
|
state.RefreshToken = refreshToken
|
||||||
|
}
|
||||||
|
if expiresIn, ok := v["expires_in"].(float64); ok {
|
||||||
|
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
delete(extensionAuthState, r.extensionID)
|
||||||
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
delete(pendingAuthRequests, r.extensionID)
|
||||||
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] Auth state cleared\n", r.extensionID)
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(state.IsAuthenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"access_token": state.AccessToken,
|
||||||
|
"refresh_token": state.RefreshToken,
|
||||||
|
"is_authenticated": state.IsAuthenticated,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.ExpiresAt.IsZero() {
|
||||||
|
result["expires_at"] = state.ExpiresAt.Unix()
|
||||||
|
result["is_expired"] = time.Now().After(state.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PKCE Support ====================
|
||||||
|
|
||||||
|
// generatePKCEVerifier generates a cryptographically random code verifier
|
||||||
|
// Length should be between 43-128 characters (RFC 7636)
|
||||||
|
func generatePKCEVerifier(length int) (string, error) {
|
||||||
|
if length < 43 {
|
||||||
|
length = 43
|
||||||
|
}
|
||||||
|
if length > 128 {
|
||||||
|
length = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes := make([]byte, length)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
|
||||||
|
if len(verifier) > length {
|
||||||
|
verifier = verifier[:length]
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePKCEChallenge(verifier string) string {
|
||||||
|
hash := sha256.Sum256([]byte(verifier))
|
||||||
|
// Base64url encode without padding (RFC 7636)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||||
|
length := 64
|
||||||
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
|
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||||
|
length = int(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier, err := generatePKCEVerifier(length)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge := generatePKCEChallenge(verifier)
|
||||||
|
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[r.extensionID] = state
|
||||||
|
}
|
||||||
|
state.PKCEVerifier = verifier
|
||||||
|
state.PKCEChallenge = challenge
|
||||||
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"verifier": verifier,
|
||||||
|
"challenge": challenge,
|
||||||
|
"method": "S256",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
if !exists || state.PKCEVerifier == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"verifier": state.PKCEVerifier,
|
||||||
|
"challenge": state.PKCEChallenge,
|
||||||
|
"method": "S256",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||||
|
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "config object is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
configObj := call.Arguments[0].Export()
|
||||||
|
config, ok := configObj.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "config must be an object",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL, _ := config["authUrl"].(string)
|
||||||
|
clientID, _ := config["clientId"].(string)
|
||||||
|
redirectURI, _ := config["redirectUri"].(string)
|
||||||
|
|
||||||
|
if authURL == "" || clientID == "" || redirectURI == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "authUrl, clientId, and redirectUri are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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, fullAuthURL)
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"authUrl": fullAuthURL,
|
||||||
|
"pkce": map[string]interface{}{
|
||||||
|
"verifier": verifier,
|
||||||
|
"challenge": challenge,
|
||||||
|
"method": "S256",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
|
||||||
|
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||||
|
// Uses the stored PKCE verifier automatically
|
||||||
|
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "config object is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
configObj := call.Arguments[0].Export()
|
||||||
|
config, ok := configObj.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "config must be an object",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
tokenURL, _ := config["tokenUrl"].(string)
|
||||||
|
clientID, _ := config["clientId"].(string)
|
||||||
|
redirectURI, _ := config["redirectUri"].(string)
|
||||||
|
code, _ := config["code"].(string)
|
||||||
|
|
||||||
|
if tokenURL == "" || clientID == "" || code == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "tokenUrl, clientId, and code are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionAuthStateMu.RLock()
|
||||||
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
|
var verifier string
|
||||||
|
if exists {
|
||||||
|
verifier = state.PKCEVerifier
|
||||||
|
}
|
||||||
|
extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
|
if verifier == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.validateDomain(tokenURL); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
formData := url.Values{}
|
||||||
|
formData.Set("grant_type", "authorization_code")
|
||||||
|
formData.Set("client_id", clientID)
|
||||||
|
formData.Set("code", code)
|
||||||
|
formData.Set("code_verifier", verifier)
|
||||||
|
if redirectURI != "" {
|
||||||
|
formData.Set("redirect_uri", redirectURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range extraParams {
|
||||||
|
formData.Set(k, fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||||
|
"body": string(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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": string(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionAuthStateMu.Lock()
|
||||||
|
state, exists = extensionAuthState[r.extensionID]
|
||||||
|
if !exists {
|
||||||
|
state = &ExtensionAuthState{}
|
||||||
|
extensionAuthState[r.extensionID] = state
|
||||||
|
}
|
||||||
|
state.AccessToken = accessToken
|
||||||
|
state.RefreshToken = refreshToken
|
||||||
|
state.IsAuthenticated = true
|
||||||
|
if expiresIn > 0 {
|
||||||
|
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
state.PKCEVerifier = ""
|
||||||
|
state.PKCEChallenge = ""
|
||||||
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"access_token": accessToken,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
"token_type": tokenResp["token_type"],
|
||||||
|
}
|
||||||
|
if expiresIn > 0 {
|
||||||
|
result["expires_in"] = expiresIn
|
||||||
|
}
|
||||||
|
if scope, ok := tokenResp["scope"].(string); ok {
|
||||||
|
result["scope"] = scope
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Package gobackend provides FFmpeg API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== FFmpeg API (Post-Processing) ====================
|
||||||
|
|
||||||
|
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
||||||
|
type FFmpegCommand struct {
|
||||||
|
ExtensionID string
|
||||||
|
Command string
|
||||||
|
InputPath string
|
||||||
|
OutputPath string
|
||||||
|
Completed bool
|
||||||
|
Success bool
|
||||||
|
Error string
|
||||||
|
Output string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global FFmpeg command queue
|
||||||
|
var (
|
||||||
|
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||||
|
ffmpegCommandsMu sync.RWMutex
|
||||||
|
ffmpegCommandID int64
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||||
|
ffmpegCommandsMu.RLock()
|
||||||
|
defer ffmpegCommandsMu.RUnlock()
|
||||||
|
return ffmpegCommands[commandID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||||
|
ffmpegCommandsMu.Lock()
|
||||||
|
defer ffmpegCommandsMu.Unlock()
|
||||||
|
if cmd, exists := ffmpegCommands[commandID]; exists {
|
||||||
|
cmd.Completed = true
|
||||||
|
cmd.Success = success
|
||||||
|
cmd.Output = output
|
||||||
|
cmd.Error = errorMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearFFmpegCommand(commandID string) {
|
||||||
|
ffmpegCommandsMu.Lock()
|
||||||
|
defer ffmpegCommandsMu.Unlock()
|
||||||
|
delete(ffmpegCommands, commandID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "command is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
command := call.Arguments[0].String()
|
||||||
|
|
||||||
|
ffmpegCommandsMu.Lock()
|
||||||
|
ffmpegCommandID++
|
||||||
|
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||||
|
ffmpegCommands[cmdID] = &FFmpegCommand{
|
||||||
|
ExtensionID: r.extensionID,
|
||||||
|
Command: command,
|
||||||
|
Completed: false,
|
||||||
|
}
|
||||||
|
ffmpegCommandsMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||||
|
|
||||||
|
timeout := 5 * time.Minute
|
||||||
|
start := time.Now()
|
||||||
|
for {
|
||||||
|
ffmpegCommandsMu.RLock()
|
||||||
|
cmd := ffmpegCommands[cmdID]
|
||||||
|
completed := cmd != nil && cmd.Completed
|
||||||
|
ffmpegCommandsMu.RUnlock()
|
||||||
|
|
||||||
|
if completed {
|
||||||
|
ffmpegCommandsMu.RLock()
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"success": cmd.Success,
|
||||||
|
"output": cmd.Output,
|
||||||
|
}
|
||||||
|
if cmd.Error != "" {
|
||||||
|
result["error"] = cmd.Error
|
||||||
|
}
|
||||||
|
ffmpegCommandsMu.RUnlock()
|
||||||
|
|
||||||
|
ClearFFmpegCommand(cmdID)
|
||||||
|
return r.vm.ToValue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(start) > timeout {
|
||||||
|
ClearFFmpegCommand(cmdID)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "FFmpeg command timed out",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "file path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := call.Arguments[0].String()
|
||||||
|
|
||||||
|
quality, err := GetAudioQuality(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"bit_depth": quality.BitDepth,
|
||||||
|
"sample_rate": quality.SampleRate,
|
||||||
|
"total_samples": quality.TotalSamples,
|
||||||
|
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "input and output paths are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
inputPath := call.Arguments[0].String()
|
||||||
|
outputPath := call.Arguments[1].String()
|
||||||
|
|
||||||
|
options := map[string]interface{}{}
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||||
|
options = opts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmdParts []string
|
||||||
|
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||||
|
|
||||||
|
if codec, ok := options["codec"].(string); ok {
|
||||||
|
cmdParts = append(cmdParts, "-c:a", codec)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bitrate, ok := options["bitrate"].(string); ok {
|
||||||
|
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||||
|
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if channels, ok := options["channels"].(float64); ok {
|
||||||
|
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||||
|
|
||||||
|
command := strings.Join(cmdParts, " ")
|
||||||
|
|
||||||
|
execCall := goja.FunctionCall{
|
||||||
|
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||||
|
}
|
||||||
|
return r.ffmpegExecute(execCall)
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
// Package gobackend provides File API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== File API (Sandboxed) ====================
|
||||||
|
|
||||||
|
var (
|
||||||
|
allowedDownloadDirs []string
|
||||||
|
allowedDownloadDirsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetAllowedDownloadDirs(dirs []string) {
|
||||||
|
allowedDownloadDirsMu.Lock()
|
||||||
|
defer allowedDownloadDirsMu.Unlock()
|
||||||
|
allowedDownloadDirs = dirs
|
||||||
|
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddAllowedDownloadDir(dir string) {
|
||||||
|
allowedDownloadDirsMu.Lock()
|
||||||
|
defer allowedDownloadDirsMu.Unlock()
|
||||||
|
absDir, err := filepath.Abs(dir)
|
||||||
|
if err == nil {
|
||||||
|
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPathInAllowedDirs(absPath string) bool {
|
||||||
|
allowedDownloadDirsMu.RLock()
|
||||||
|
defer allowedDownloadDirsMu.RUnlock()
|
||||||
|
|
||||||
|
for _, allowedDir := range allowedDownloadDirs {
|
||||||
|
if isPathWithinBase(allowedDir, absPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPathWithinBase(baseDir, targetPath string) bool {
|
||||||
|
baseAbs, err := filepath.Abs(baseDir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
targetAbs, err := filepath.Abs(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(baseAbs, targetAbs)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel = filepath.Clean(rel)
|
||||||
|
if rel == "." {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := ".." + string(filepath.Separator)
|
||||||
|
if rel == ".." || strings.HasPrefix(rel, prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||||
|
if !r.manifest.Permissions.File {
|
||||||
|
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanPath := filepath.Clean(path)
|
||||||
|
|
||||||
|
if filepath.IsAbs(cleanPath) {
|
||||||
|
absPath, err := filepath.Abs(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPathInAllowedDirs(absPath) {
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||||
|
if !isPathWithinBase(absDataDir, absPath) {
|
||||||
|
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "URL and output path are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
outputPath := call.Arguments[1].String()
|
||||||
|
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath, err := r.validatePath(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var onProgress goja.Callable
|
||||||
|
var headers map[string]string
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
optionsObj := call.Arguments[2].Export()
|
||||||
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
|
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||||
|
headers = make(map[string]string)
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if progressVal, ok := opts["onProgress"]; ok {
|
||||||
|
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||||
|
onProgress = callable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(fullPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
contentLength := resp.ContentLength
|
||||||
|
|
||||||
|
var written int64
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
nr, er := resp.Body.Read(buf)
|
||||||
|
if nr > 0 {
|
||||||
|
nw, ew := out.Write(buf[0:nr])
|
||||||
|
if nw < 0 || nr < nw {
|
||||||
|
nw = 0
|
||||||
|
if ew == nil {
|
||||||
|
ew = fmt.Errorf("invalid write result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
written += int64(nw)
|
||||||
|
if ew != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if nr != nw {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "short write",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if onProgress != nil && contentLength > 0 {
|
||||||
|
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if er != nil {
|
||||||
|
if er != io.EOF {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read response: %v", er),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullPath,
|
||||||
|
"size": written,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(fullPath)
|
||||||
|
return r.vm.ToValue(err == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(fullPath); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": string(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path and data are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
data := call.Arguments[1].String()
|
||||||
|
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(fullPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "source and destination paths are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
srcPath := call.Arguments[0].String()
|
||||||
|
dstPath := call.Arguments[1].String()
|
||||||
|
|
||||||
|
fullSrc, err := r.validatePath(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fullDst, err := r.validatePath(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(fullSrc)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read source: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(fullDst)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to write destination: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullDst,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "source and destination paths are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
srcPath := call.Arguments[0].String()
|
||||||
|
dstPath := call.Arguments[1].String()
|
||||||
|
|
||||||
|
fullSrc, err := r.validatePath(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fullDst, err := r.validatePath(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(fullDst)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(fullSrc, fullDst); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to move file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullDst,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
path := call.Arguments[0].String()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"size": info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
// Package gobackend provides HTTP API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== HTTP API (Sandboxed) ====================
|
||||||
|
|
||||||
|
type HTTPResponse struct {
|
||||||
|
StatusCode int `json:"statusCode"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Scheme == "" {
|
||||||
|
return fmt.Errorf("invalid URL: scheme is required")
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("network access denied: only https is allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := parsed.Hostname()
|
||||||
|
if domain == "" {
|
||||||
|
return fmt.Errorf("invalid URL: hostname is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPrivateIP(domain) {
|
||||||
|
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.manifest.IsDomainAllowed(domain) {
|
||||||
|
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "URL is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make(map[string]string)
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
headersObj := call.Arguments[1].Export()
|
||||||
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", urlStr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"body": string(body),
|
||||||
|
"headers": respHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "URL is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyStr string
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
bodyArg := call.Arguments[1].Export()
|
||||||
|
switch v := bodyArg.(type) {
|
||||||
|
case string:
|
||||||
|
bodyStr = v
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
jsonBytes, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyStr = string(jsonBytes)
|
||||||
|
default:
|
||||||
|
bodyStr = call.Arguments[1].String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make(map[string]string)
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
headersObj := call.Arguments[2].Export()
|
||||||
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
|
}
|
||||||
|
if req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"body": string(body),
|
||||||
|
"headers": respHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "URL is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
method := "GET"
|
||||||
|
var bodyStr string
|
||||||
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
optionsObj := call.Arguments[1].Export()
|
||||||
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
|
if m, ok := opts["method"].(string); ok {
|
||||||
|
method = strings.ToUpper(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||||
|
switch v := bodyArg.(type) {
|
||||||
|
case string:
|
||||||
|
bodyStr = v
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
jsonBytes, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyStr = string(jsonBytes)
|
||||||
|
default:
|
||||||
|
bodyStr = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody io.Reader
|
||||||
|
if bodyStr != "" {
|
||||||
|
reqBody = strings.NewReader(bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
|
}
|
||||||
|
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"body": string(body),
|
||||||
|
"headers": respHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.httpMethodShortcut("PUT", call)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.httpMethodShortcut("DELETE", call)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.httpMethodShortcut("PATCH", call)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "URL is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyStr string
|
||||||
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
if method == "DELETE" {
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
headersObj := call.Arguments[1].Export()
|
||||||
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
bodyArg := call.Arguments[1].Export()
|
||||||
|
switch v := bodyArg.(type) {
|
||||||
|
case string:
|
||||||
|
bodyStr = v
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
jsonBytes, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bodyStr = string(jsonBytes)
|
||||||
|
default:
|
||||||
|
bodyStr = call.Arguments[1].String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
headersObj := call.Arguments[2].Export()
|
||||||
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody io.Reader
|
||||||
|
if bodyStr != "" {
|
||||||
|
reqBody = strings.NewReader(bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
|
}
|
||||||
|
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"body": string(body),
|
||||||
|
"headers": respHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||||
|
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||||
|
jar.mu.Lock()
|
||||||
|
jar.cookies = make(map[string][]*http.Cookie)
|
||||||
|
jar.mu.Unlock()
|
||||||
|
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Package gobackend provides Track Matching API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Track Matching API ====================
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
||||||
|
str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String()))
|
||||||
|
|
||||||
|
if str1 == str2 {
|
||||||
|
return r.vm.ToValue(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
similarity := calculateStringSimilarity(str1, str2)
|
||||||
|
return r.vm.ToValue(similarity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
dur1 := int(call.Arguments[0].ToInteger())
|
||||||
|
dur2 := int(call.Arguments[1].ToInteger())
|
||||||
|
|
||||||
|
tolerance := 3000
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||||
|
tolerance = int(call.Arguments[2].ToInteger())
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := dur1 - dur2
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(diff <= tolerance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
str := call.Arguments[0].String()
|
||||||
|
normalized := normalizeStringForMatching(str)
|
||||||
|
return r.vm.ToValue(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||||
|
if len(s1) == 0 && len(s2) == 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
if len(s1) == 0 || len(s2) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
distance := levenshteinDistance(s1, s2)
|
||||||
|
maxLen := len(s1)
|
||||||
|
if len(s2) > maxLen {
|
||||||
|
maxLen = len(s2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1.0 - float64(distance)/float64(maxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func levenshteinDistance(s1, s2 string) int {
|
||||||
|
if len(s1) == 0 {
|
||||||
|
return len(s2)
|
||||||
|
}
|
||||||
|
if len(s2) == 0 {
|
||||||
|
return len(s1)
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix := make([][]int, len(s1)+1)
|
||||||
|
for i := range matrix {
|
||||||
|
matrix[i] = make([]int, len(s2)+1)
|
||||||
|
matrix[i][0] = i
|
||||||
|
}
|
||||||
|
for j := range matrix[0] {
|
||||||
|
matrix[0][j] = j
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 1; i <= len(s1); i++ {
|
||||||
|
for j := 1; j <= len(s2); j++ {
|
||||||
|
cost := 1
|
||||||
|
if s1[i-1] == s2[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
matrix[i][j] = min(
|
||||||
|
matrix[i-1][j]+1,
|
||||||
|
matrix[i][j-1]+1,
|
||||||
|
matrix[i-1][j-1]+cost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix[len(s1)][len(s2)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStringForMatching(s string) string {
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
|
||||||
|
suffixes := []string{
|
||||||
|
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||||
|
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||||
|
" (explicit)", " (clean)", " [explicit]", " [clean]",
|
||||||
|
" (album version)", " (single version)", " (radio edit)",
|
||||||
|
" (feat.", " (ft.", " feat.", " ft.",
|
||||||
|
}
|
||||||
|
for _, suffix := range suffixes {
|
||||||
|
if idx := strings.Index(s, suffix); idx != -1 {
|
||||||
|
s = s[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s = strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
// Package gobackend provides Browser-like Polyfills for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Browser-like Polyfills ====================
|
||||||
|
// These polyfills make porting browser/Node.js libraries easier
|
||||||
|
// without compromising sandbox security
|
||||||
|
|
||||||
|
// fetchPolyfill implements browser-compatible fetch() API
|
||||||
|
// Returns a Promise-like object with json(), text() methods
|
||||||
|
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.createFetchError("URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
|
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||||
|
return r.createFetchError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
method := "GET"
|
||||||
|
var bodyStr string
|
||||||
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
|
optionsObj := call.Arguments[1].Export()
|
||||||
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
|
if m, ok := opts["method"].(string); ok {
|
||||||
|
method = strings.ToUpper(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body - support string, object (auto-stringify), or nil
|
||||||
|
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||||
|
switch v := bodyArg.(type) {
|
||||||
|
case string:
|
||||||
|
bodyStr = v
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
jsonBytes, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
|
||||||
|
}
|
||||||
|
bodyStr = string(jsonBytes)
|
||||||
|
default:
|
||||||
|
bodyStr = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h, ok := opts["headers"]; ok && h != nil {
|
||||||
|
switch hv := h.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
for k, v := range hv {
|
||||||
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reqBody io.Reader
|
||||||
|
if bodyStr != "" {
|
||||||
|
reqBody = strings.NewReader(bodyStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return r.createFetchError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
|
}
|
||||||
|
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return r.createFetchError(err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return r.createFetchError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
respHeaders := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
respHeaders[k] = v[0]
|
||||||
|
} else {
|
||||||
|
respHeaders[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responseObj := r.vm.NewObject()
|
||||||
|
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||||
|
responseObj.Set("status", resp.StatusCode)
|
||||||
|
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||||
|
responseObj.Set("headers", respHeaders)
|
||||||
|
responseObj.Set("url", urlStr)
|
||||||
|
|
||||||
|
bodyString := string(body)
|
||||||
|
|
||||||
|
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.vm.ToValue(bodyString)
|
||||||
|
})
|
||||||
|
|
||||||
|
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||||
|
var result interface{}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||||
|
byteArray := make([]interface{}, len(body))
|
||||||
|
for i, b := range body {
|
||||||
|
byteArray[i] = int(b)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(byteArray)
|
||||||
|
})
|
||||||
|
|
||||||
|
return responseObj
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFetchError creates a fetch error response
|
||||||
|
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||||
|
errorObj := r.vm.NewObject()
|
||||||
|
errorObj.Set("ok", false)
|
||||||
|
errorObj.Set("status", 0)
|
||||||
|
errorObj.Set("statusText", "Network Error")
|
||||||
|
errorObj.Set("error", message)
|
||||||
|
errorObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
})
|
||||||
|
errorObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
return errorObj
|
||||||
|
}
|
||||||
|
|
||||||
|
// atobPolyfill implements browser atob() - decode base64 to string
|
||||||
|
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(string(decoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
// btoaPolyfill implements browser btoa() - encode string to base64
|
||||||
|
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||||
|
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
|
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
|
encoder := call.This
|
||||||
|
encoder.Set("encoding", "utf-8")
|
||||||
|
|
||||||
|
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue([]byte{})
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
bytes := []byte(input)
|
||||||
|
|
||||||
|
result := make([]interface{}, len(bytes))
|
||||||
|
for i, b := range bytes {
|
||||||
|
result[i] = int(b)
|
||||||
|
}
|
||||||
|
return vm.ToValue(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||||
|
// Simplified implementation
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"read": len(input),
|
||||||
|
"written": len([]byte(input)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
|
decoder := call.This
|
||||||
|
|
||||||
|
encoding := "utf-8"
|
||||||
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
|
encoding = call.Arguments[0].String()
|
||||||
|
}
|
||||||
|
decoder.Set("encoding", encoding)
|
||||||
|
decoder.Set("fatal", false)
|
||||||
|
decoder.Set("ignoreBOM", false)
|
||||||
|
|
||||||
|
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
input := call.Arguments[0].Export()
|
||||||
|
var bytes []byte
|
||||||
|
|
||||||
|
switch v := input.(type) {
|
||||||
|
case []byte:
|
||||||
|
bytes = v
|
||||||
|
case []interface{}:
|
||||||
|
bytes = make([]byte, len(v))
|
||||||
|
for i, val := range v {
|
||||||
|
switch n := val.(type) {
|
||||||
|
case int64:
|
||||||
|
bytes[i] = byte(n)
|
||||||
|
case float64:
|
||||||
|
bytes[i] = byte(n)
|
||||||
|
case int:
|
||||||
|
bytes[i] = byte(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
return vm.ToValue(v)
|
||||||
|
default:
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm.ToValue(string(bytes))
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||||
|
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||||
|
urlObj := call.This
|
||||||
|
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
urlObj.Set("href", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||||
|
baseStr := call.Arguments[1].String()
|
||||||
|
baseURL, err := url.Parse(baseStr)
|
||||||
|
if err == nil {
|
||||||
|
relURL, err := url.Parse(urlStr)
|
||||||
|
if err == nil {
|
||||||
|
urlStr = baseURL.ResolveReference(relURL).String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
urlObj.Set("href", urlStr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
urlObj.Set("href", parsed.String())
|
||||||
|
urlObj.Set("protocol", parsed.Scheme+":")
|
||||||
|
urlObj.Set("host", parsed.Host)
|
||||||
|
urlObj.Set("hostname", parsed.Hostname())
|
||||||
|
urlObj.Set("port", parsed.Port())
|
||||||
|
urlObj.Set("pathname", parsed.Path)
|
||||||
|
urlObj.Set("search", "")
|
||||||
|
if parsed.RawQuery != "" {
|
||||||
|
urlObj.Set("search", "?"+parsed.RawQuery)
|
||||||
|
}
|
||||||
|
urlObj.Set("hash", "")
|
||||||
|
if parsed.Fragment != "" {
|
||||||
|
urlObj.Set("hash", "#"+parsed.Fragment)
|
||||||
|
}
|
||||||
|
urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host)
|
||||||
|
urlObj.Set("username", parsed.User.Username())
|
||||||
|
password, _ := parsed.User.Password()
|
||||||
|
urlObj.Set("password", password)
|
||||||
|
|
||||||
|
queryValues := parsed.Query()
|
||||||
|
|
||||||
|
searchParams := vm.NewObject()
|
||||||
|
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Null()
|
||||||
|
}
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
if val := queryValues.Get(key); val != "" {
|
||||||
|
return vm.ToValue(val)
|
||||||
|
}
|
||||||
|
return goja.Null()
|
||||||
|
})
|
||||||
|
|
||||||
|
searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue([]string{})
|
||||||
|
}
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
return vm.ToValue(queryValues[key])
|
||||||
|
})
|
||||||
|
|
||||||
|
searchParams.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue(false)
|
||||||
|
}
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
return vm.ToValue(queryValues.Has(key))
|
||||||
|
})
|
||||||
|
|
||||||
|
searchParams.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return vm.ToValue(queryValues.Encode())
|
||||||
|
})
|
||||||
|
|
||||||
|
urlObj.Set("searchParams", searchParams)
|
||||||
|
|
||||||
|
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return vm.ToValue(parsed.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return vm.ToValue(parsed.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||||
|
paramsObj := call.This
|
||||||
|
values := url.Values{}
|
||||||
|
|
||||||
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
|
init := call.Arguments[0].Export()
|
||||||
|
switch v := init.(type) {
|
||||||
|
case string:
|
||||||
|
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||||
|
values = parsed
|
||||||
|
case map[string]interface{}:
|
||||||
|
for k, val := range v {
|
||||||
|
values.Set(k, fmt.Sprintf("%v", val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paramsObj.Set("append", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) >= 2 {
|
||||||
|
values.Add(call.Arguments[0].String(), call.Arguments[1].String())
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) >= 1 {
|
||||||
|
values.Del(call.Arguments[0].String())
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Null()
|
||||||
|
}
|
||||||
|
if val := values.Get(call.Arguments[0].String()); val != "" {
|
||||||
|
return vm.ToValue(val)
|
||||||
|
}
|
||||||
|
return goja.Null()
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue([]string{})
|
||||||
|
}
|
||||||
|
return vm.ToValue(values[call.Arguments[0].String()])
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue(false)
|
||||||
|
}
|
||||||
|
return vm.ToValue(values.Has(call.Arguments[0].String()))
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("set", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) >= 2 {
|
||||||
|
values.Set(call.Arguments[0].String(), call.Arguments[1].String())
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||||
|
return vm.ToValue(values.Encode())
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerJSONGlobal ensures JSON global is properly set up
|
||||||
|
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||||
|
// JSON is already built-in to Goja, but we can enhance it
|
||||||
|
jsonScript := `
|
||||||
|
if (typeof JSON === 'undefined') {
|
||||||
|
var JSON = {
|
||||||
|
parse: function(text) {
|
||||||
|
return utils.parseJSON(text);
|
||||||
|
},
|
||||||
|
stringify: function(value, replacer, space) {
|
||||||
|
return utils.stringifyJSON(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`
|
||||||
|
_, _ = vm.RunString(jsonScript)
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
// Package gobackend provides Storage and Credentials API for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Storage API ====================
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) getStoragePath() string {
|
||||||
|
return filepath.Join(r.dataDir, "storage.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||||
|
storagePath := r.getStoragePath()
|
||||||
|
data, err := os.ReadFile(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var storage map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &storage); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return storage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||||
|
storagePath := r.getStoragePath()
|
||||||
|
data, err := json.MarshalIndent(storage, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(storagePath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
storage, err := r.loadStorage()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
value, exists := storage[key]
|
||||||
|
if !exists {
|
||||||
|
if len(call.Arguments) > 1 {
|
||||||
|
return call.Arguments[1]
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
value := call.Arguments[1].Export()
|
||||||
|
|
||||||
|
storage, err := r.loadStorage()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage[key] = value
|
||||||
|
|
||||||
|
if err := r.saveStorage(storage); err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
storage, err := r.loadStorage()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(storage, key)
|
||||||
|
|
||||||
|
if err := r.saveStorage(storage); err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||||
|
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) getSaltPath() string {
|
||||||
|
return filepath.Join(r.dataDir, ".cred_salt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||||
|
saltPath := r.getSaltPath()
|
||||||
|
|
||||||
|
salt, err := os.ReadFile(saltPath)
|
||||||
|
if err == nil && len(salt) == 32 {
|
||||||
|
return salt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
salt = make([]byte, 32)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return salt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||||
|
salt, err := r.getOrCreateSalt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
combined := append([]byte(r.extensionID), salt...)
|
||||||
|
hash := sha256.Sum256(combined)
|
||||||
|
return hash[:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||||
|
credPath := r.getCredentialsPath()
|
||||||
|
data, err := os.ReadFile(credPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := r.getEncryptionKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||||
|
}
|
||||||
|
decrypted, err := decryptAES(data, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds map[string]interface{}
|
||||||
|
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||||
|
data, err := json.Marshal(creds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := r.getEncryptionKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||||
|
}
|
||||||
|
encrypted, err := encryptAES(data, key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
credPath := r.getCredentialsPath()
|
||||||
|
return os.WriteFile(credPath, encrypted, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "key and value are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
value := call.Arguments[1].Export()
|
||||||
|
|
||||||
|
creds, err := r.loadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
creds[key] = value
|
||||||
|
|
||||||
|
if err := r.saveCredentials(creds); err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
creds, err := r.loadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
value, exists := creds[key]
|
||||||
|
if !exists {
|
||||||
|
if len(call.Arguments) > 1 {
|
||||||
|
return call.Arguments[1]
|
||||||
|
}
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
creds, err := r.loadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(creds, key)
|
||||||
|
|
||||||
|
if err := r.saveCredentials(creds); err != nil {
|
||||||
|
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
|
creds, err := r.loadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, exists := creds[key]
|
||||||
|
return r.vm.ToValue(exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(ciphertext) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
// Package gobackend provides Utility functions for extension runtime
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== Utility Functions ====================
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(string(decoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
hash := md5.Sum([]byte(input))
|
||||||
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
hash := sha256.Sum256([]byte(input))
|
||||||
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
message := call.Arguments[0].String()
|
||||||
|
key := call.Arguments[1].String()
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
|
mac.Write([]byte(message))
|
||||||
|
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
message := call.Arguments[0].String()
|
||||||
|
key := call.Arguments[1].String()
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
|
mac.Write([]byte(message))
|
||||||
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue([]byte{})
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyBytes []byte
|
||||||
|
keyArg := call.Arguments[0].Export()
|
||||||
|
switch k := keyArg.(type) {
|
||||||
|
case string:
|
||||||
|
keyBytes = []byte(k)
|
||||||
|
case []interface{}:
|
||||||
|
keyBytes = make([]byte, len(k))
|
||||||
|
for i, v := range k {
|
||||||
|
if num, ok := v.(int64); ok {
|
||||||
|
keyBytes[i] = byte(num)
|
||||||
|
} else if num, ok := v.(float64); ok {
|
||||||
|
keyBytes[i] = byte(int(num))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return r.vm.ToValue([]byte{})
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgBytes []byte
|
||||||
|
msgArg := call.Arguments[1].Export()
|
||||||
|
switch m := msgArg.(type) {
|
||||||
|
case string:
|
||||||
|
msgBytes = []byte(m)
|
||||||
|
case []interface{}:
|
||||||
|
msgBytes = make([]byte, len(m))
|
||||||
|
for i, v := range m {
|
||||||
|
if num, ok := v.(int64); ok {
|
||||||
|
msgBytes[i] = byte(num)
|
||||||
|
} else if num, ok := v.(float64); ok {
|
||||||
|
msgBytes[i] = byte(int(num))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return r.vm.ToValue([]byte{})
|
||||||
|
}
|
||||||
|
|
||||||
|
mac := hmac.New(sha1.New, keyBytes)
|
||||||
|
mac.Write(msgBytes)
|
||||||
|
result := mac.Sum(nil)
|
||||||
|
|
||||||
|
jsArray := make([]interface{}, len(result))
|
||||||
|
for i, b := range result {
|
||||||
|
jsArray[i] = int(b)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(jsArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
if err := json.Unmarshal([]byte(input), &result); err != nil {
|
||||||
|
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].Export()
|
||||||
|
|
||||||
|
data, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err)
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "plaintext and key are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := call.Arguments[0].String()
|
||||||
|
keyStr := call.Arguments[1].String()
|
||||||
|
|
||||||
|
keyHash := sha256.Sum256([]byte(keyStr))
|
||||||
|
|
||||||
|
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": base64.StdEncoding.EncodeToString(encrypted),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "ciphertext and key are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertextB64 := call.Arguments[0].String()
|
||||||
|
keyStr := call.Arguments[1].String()
|
||||||
|
|
||||||
|
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "invalid base64 ciphertext",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
keyHash := sha256.Sum256([]byte(keyStr))
|
||||||
|
|
||||||
|
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "invalid base64 ciphertext",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": string(decrypted),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||||
|
length := 32
|
||||||
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
|
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||||
|
length = int(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key := make([]byte, length)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"key": base64.StdEncoding.EncodeToString(key),
|
||||||
|
"hex": hex.EncodeToString(key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.vm.ToValue(getRandomUserAgent())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||||
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
|
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||||
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
|
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||||
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
|
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||||
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
|
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||||
|
return goja.Undefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||||
|
parts := make([]string, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return r.vm.ToValue("")
|
||||||
|
}
|
||||||
|
input := call.Arguments[0].String()
|
||||||
|
return r.vm.ToValue(sanitizeFilename(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||||
|
gobackendObj := vm.Get("gobackend")
|
||||||
|
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||||
|
gobackendObj = vm.NewObject()
|
||||||
|
vm.Set("gobackend", gobackendObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := gobackendObj.(*goja.Object)
|
||||||
|
|
||||||
|
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||||
|
})
|
||||||
|
|
||||||
|
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 1 {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": "file path is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := call.Arguments[0].String()
|
||||||
|
quality, err := GetAudioQuality(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"bitDepth": quality.BitDepth,
|
||||||
|
"sampleRate": quality.SampleRate,
|
||||||
|
"totalSamples": quality.TotalSamples,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
template := call.Arguments[0].String()
|
||||||
|
metadataObj := call.Arguments[1].Export()
|
||||||
|
|
||||||
|
metadata, ok := metadataObj.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return vm.ToValue("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||||
|
})
|
||||||
|
|
||||||
|
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
|
||||||
|
now := time.Now()
|
||||||
|
_, offsetSeconds := now.Zone()
|
||||||
|
offsetMinutes := offsetSeconds / 60
|
||||||
|
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"year": now.Year(),
|
||||||
|
"month": int(now.Month()),
|
||||||
|
"day": now.Day(),
|
||||||
|
"hour": now.Hour(),
|
||||||
|
"minute": now.Minute(),
|
||||||
|
"second": now.Second(),
|
||||||
|
"weekday": int(now.Weekday()),
|
||||||
|
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
|
||||||
|
"timezone": now.Location().String(),
|
||||||
|
"timestamp": now.Unix(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Package gobackend provides extension settings storage
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtensionSettingsStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
dataDir string
|
||||||
|
settings map[string]map[string]interface{} // extensionID -> settings
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalSettingsStore *ExtensionSettingsStore
|
||||||
|
globalSettingsStoreOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||||
|
globalSettingsStoreOnce.Do(func() {
|
||||||
|
globalSettingsStore = &ExtensionSettingsStore{
|
||||||
|
settings: make(map[string]map[string]interface{}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalSettingsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.dataDir = dataDir
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.loadAllSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
||||||
|
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||||
|
entries, err := os.ReadDir(s.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
extensionID := entry.Name()
|
||||||
|
settings, err := s.loadSettings(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[ExtensionSettings] Failed to load settings for %s: %v\n", extensionID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.settings[extensionID] = settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||||
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
|
data, err := os.ReadFile(settingsPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||||
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
|
|
||||||
|
dir := filepath.Dir(settingsPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(settings, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(settingsPath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
extSettings, exists := s.settings[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("extension '%s' settings not found", extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, exists := extSettings[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("setting '%s' not found for extension '%s'", key, extensionID)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
extSettings, exists := s.settings[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
for k, v := range extSettings {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := s.settings[extensionID]; !exists {
|
||||||
|
s.settings[extensionID] = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
s.settings[extensionID][key] = value
|
||||||
|
|
||||||
|
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.settings[extensionID] = settings
|
||||||
|
|
||||||
|
return s.saveSettings(extensionID, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
extSettings, exists := s.settings[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(extSettings, key)
|
||||||
|
|
||||||
|
return s.saveSettings(extensionID, extSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
delete(s.settings, extensionID)
|
||||||
|
|
||||||
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
|
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := json.Marshal(s.settings)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CategoryMetadata = "metadata"
|
||||||
|
CategoryDownload = "download"
|
||||||
|
CategoryUtility = "utility"
|
||||||
|
CategoryLyrics = "lyrics"
|
||||||
|
CategoryIntegration = "integration"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StoreExtension struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Downloads int `json:"downloads"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||||
|
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||||
|
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||||
|
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||||
|
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *StoreExtension) getDisplayName() string {
|
||||||
|
if e.DisplayName != "" {
|
||||||
|
return e.DisplayName
|
||||||
|
}
|
||||||
|
if e.DisplayNameAlt != "" {
|
||||||
|
return e.DisplayNameAlt
|
||||||
|
}
|
||||||
|
return e.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *StoreExtension) getDownloadURL() string {
|
||||||
|
if e.DownloadURL != "" {
|
||||||
|
return e.DownloadURL
|
||||||
|
}
|
||||||
|
return e.DownloadURLAlt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *StoreExtension) getIconURL() string {
|
||||||
|
if e.IconURL != "" {
|
||||||
|
return e.IconURL
|
||||||
|
}
|
||||||
|
return e.IconURLAlt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *StoreExtension) getMinAppVersion() string {
|
||||||
|
if e.MinAppVersion != "" {
|
||||||
|
return e.MinAppVersion
|
||||||
|
}
|
||||||
|
return e.MinAppVersionAlt
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoreRegistry struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Extensions []StoreExtension `json:"extensions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreExtensionResponse is the normalized response sent to Flutter
|
||||||
|
type StoreExtensionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DownloadURL string `json:"download_url"`
|
||||||
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Downloads int `json:"downloads"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||||
|
IsInstalled bool `json:"is_installed"`
|
||||||
|
InstalledVersion string `json:"installed_version,omitempty"`
|
||||||
|
HasUpdate bool `json:"has_update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||||
|
return StoreExtensionResponse{
|
||||||
|
ID: e.ID,
|
||||||
|
Name: e.Name,
|
||||||
|
DisplayName: e.getDisplayName(),
|
||||||
|
Version: e.Version,
|
||||||
|
Author: e.Author,
|
||||||
|
Description: e.Description,
|
||||||
|
DownloadURL: e.getDownloadURL(),
|
||||||
|
IconURL: e.getIconURL(),
|
||||||
|
Category: e.Category,
|
||||||
|
Tags: e.Tags,
|
||||||
|
Downloads: e.Downloads,
|
||||||
|
UpdatedAt: e.UpdatedAt,
|
||||||
|
MinAppVersion: e.getMinAppVersion(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionStore struct {
|
||||||
|
registryURL string
|
||||||
|
cacheDir string
|
||||||
|
cache *StoreRegistry
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
cacheTime time.Time
|
||||||
|
cacheTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
extensionStore *ExtensionStore
|
||||||
|
extensionStoreMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
||||||
|
cacheTTL = 30 * time.Minute
|
||||||
|
cacheFileName = "store_cache.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||||
|
extensionStoreMu.Lock()
|
||||||
|
defer extensionStoreMu.Unlock()
|
||||||
|
|
||||||
|
if extensionStore == nil {
|
||||||
|
extensionStore = &ExtensionStore{
|
||||||
|
registryURL: defaultRegistryURL,
|
||||||
|
cacheDir: cacheDir,
|
||||||
|
cacheTTL: cacheTTL,
|
||||||
|
}
|
||||||
|
extensionStore.loadDiskCache()
|
||||||
|
}
|
||||||
|
return extensionStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetExtensionStore() *ExtensionStore {
|
||||||
|
extensionStoreMu.Lock()
|
||||||
|
defer extensionStoreMu.Unlock()
|
||||||
|
return extensionStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionStore) loadDiskCache() {
|
||||||
|
if s.cacheDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
|
data, err := os.ReadFile(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheData struct {
|
||||||
|
Registry StoreRegistry `json:"registry"`
|
||||||
|
CacheTime int64 `json:"cache_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &cacheData); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cache = &cacheData.Registry
|
||||||
|
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
|
||||||
|
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionStore) saveDiskCache() {
|
||||||
|
if s.cacheDir == "" || s.cache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheData := struct {
|
||||||
|
Registry StoreRegistry `json:"registry"`
|
||||||
|
CacheTime int64 `json:"cache_time"`
|
||||||
|
}{
|
||||||
|
Registry: *s.cache,
|
||||||
|
CacheTime: s.cacheTime.Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cacheData)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
|
os.WriteFile(cachePath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||||
|
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||||
|
return s.cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(s.registryURL)
|
||||||
|
if err != nil {
|
||||||
|
if s.cache != nil {
|
||||||
|
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||||
|
return s.cache, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to fetch registry: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if s.cache != nil {
|
||||||
|
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
|
||||||
|
return s.cache, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var registry StoreRegistry
|
||||||
|
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cache = ®istry
|
||||||
|
s.cacheTime = time.Now()
|
||||||
|
s.saveDiskCache()
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||||
|
return ®istry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||||
|
registry, err := s.FetchRegistry(false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := GetExtensionManager()
|
||||||
|
installed := make(map[string]string) // id -> version
|
||||||
|
|
||||||
|
if manager != nil {
|
||||||
|
for _, ext := range manager.GetAllExtensions() {
|
||||||
|
installed[ext.ID] = ext.Manifest.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
||||||
|
for i, ext := range registry.Extensions {
|
||||||
|
resp := ext.ToResponse()
|
||||||
|
|
||||||
|
if installedVersion, ok := installed[ext.ID]; ok {
|
||||||
|
resp.IsInstalled = true
|
||||||
|
resp.InstalledVersion = installedVersion
|
||||||
|
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i] = resp
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||||
|
registry, err := s.FetchRegistry(false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext *StoreExtension
|
||||||
|
for _, e := range registry.Extensions {
|
||||||
|
if e.ID == extensionID {
|
||||||
|
ext = &e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext == nil {
|
||||||
|
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
resp, err := client.Get(ext.getDownloadURL())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(destPath)
|
||||||
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireHTTPSURL(rawURL string, context string) error {
|
||||||
|
if rawURL == "" {
|
||||||
|
return fmt.Errorf("%s URL is empty", context)
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionStore) GetCategories() []string {
|
||||||
|
return []string{
|
||||||
|
CategoryMetadata,
|
||||||
|
CategoryDownload,
|
||||||
|
CategoryUtility,
|
||||||
|
CategoryLyrics,
|
||||||
|
CategoryIntegration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||||
|
extensions, err := s.GetExtensionsWithStatus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if query == "" && category == "" {
|
||||||
|
return extensions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []StoreExtensionResponse
|
||||||
|
queryLower := toLower(query)
|
||||||
|
|
||||||
|
for _, ext := range extensions {
|
||||||
|
// Filter by category
|
||||||
|
if category != "" && ext.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by query
|
||||||
|
if query != "" {
|
||||||
|
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||||
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
|
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||||
|
!containsIgnoreCase(ext.Author, queryLower) {
|
||||||
|
// Check tags
|
||||||
|
found := false
|
||||||
|
for _, tag := range ext.Tags {
|
||||||
|
if containsIgnoreCase(tag, queryLower) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExtensionStore) ClearCache() {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
s.cache = nil
|
||||||
|
s.cacheTime = time.Time{}
|
||||||
|
|
||||||
|
if s.cacheDir != "" {
|
||||||
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
|
os.Remove(cachePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Cache cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: case-insensitive contains
|
||||||
|
func containsIgnoreCase(s, substr string) bool {
|
||||||
|
return containsStr(toLower(s), substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLower(s string) string {
|
||||||
|
result := make([]byte, len(s))
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c >= 'A' && c <= 'Z' {
|
||||||
|
c += 'a' - 'A'
|
||||||
|
}
|
||||||
|
result[i] = c
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(s, substr string) bool {
|
||||||
|
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSubstring(s, substr string) int {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseManifest_Valid(t *testing.T) {
|
||||||
|
validManifest := `{
|
||||||
|
"name": "test-provider",
|
||||||
|
"displayName": "Test Provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Test Author",
|
||||||
|
"description": "A test extension",
|
||||||
|
"type": ["metadata_provider"],
|
||||||
|
"permissions": {
|
||||||
|
"network": ["api.test.com"],
|
||||||
|
"storage": true
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
manifest, err := ParseManifest([]byte(validManifest))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected valid manifest to parse, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.Name != "test-provider" {
|
||||||
|
t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.Version != "1.0.0" {
|
||||||
|
t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manifest.IsMetadataProvider() {
|
||||||
|
t.Error("Expected IsMetadataProvider() to return true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.IsDownloadProvider() {
|
||||||
|
t.Error("Expected IsDownloadProvider() to return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseManifest_MissingName(t *testing.T) {
|
||||||
|
invalidManifest := `{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Test Author",
|
||||||
|
"description": "A test extension",
|
||||||
|
"type": ["metadata_provider"]
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := ParseManifest([]byte(invalidManifest))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseManifest_MissingType(t *testing.T) {
|
||||||
|
invalidManifest := `{
|
||||||
|
"name": "test-provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Test Author",
|
||||||
|
"description": "A test extension"
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := ParseManifest([]byte(invalidManifest))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDomainAllowed(t *testing.T) {
|
||||||
|
manifest := &ExtensionManifest{
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.test.com", "*.example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
domain string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"api.test.com", true},
|
||||||
|
{"api.example.com", true},
|
||||||
|
{"sub.example.com", true},
|
||||||
|
{"notallowed.com", false},
|
||||||
|
{"test.com", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := manifest.IsDomainAllowed(tt.domain)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||||
|
// Create a mock extension with limited network permissions
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.allowed.com", "*.wildcard.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
|
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||||
|
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil {
|
||||||
|
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||||
|
t.Error("Expected blocked.com to be denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||||
|
t.Error("Expected notallowed.com to be denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
File: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
|
validPath, err := runtime.validatePath("test.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
if validPath == "" {
|
||||||
|
t.Error("Expected non-empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = runtime.validatePath("../../../etc/passwd")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected path traversal to be blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||||
|
}
|
||||||
|
if nestedPath == "" {
|
||||||
|
t.Error("Expected non-empty nested path")
|
||||||
|
}
|
||||||
|
|
||||||
|
var absPath string
|
||||||
|
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||||
|
absPath = "C:\\Windows\\System32\\test.txt"
|
||||||
|
} else {
|
||||||
|
absPath = "/etc/passwd"
|
||||||
|
}
|
||||||
|
_, err = runtime.validatePath(absPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected absolute path to be blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
extNoFile := &LoadedExtension{
|
||||||
|
ID: "test-ext-no-file",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext-no-file",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
File: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: tempDir,
|
||||||
|
}
|
||||||
|
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
||||||
|
_, err = runtimeNoFile.validatePath("test.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected file access to be denied without file permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
vm := goja.New()
|
||||||
|
runtime.RegisterAPIs(vm)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base64Encode failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.String() != "aGVsbG8=" {
|
||||||
|
t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("base64Decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.String() != "hello" {
|
||||||
|
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = vm.RunString(`utils.md5("hello")`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("md5 failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.String() != "5d41402abc4b2a76b9719d911017c592" {
|
||||||
|
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stringifyJSON failed: %v", err)
|
||||||
|
}
|
||||||
|
// JSON output may vary in order, just check it's valid
|
||||||
|
if result.String() == "" {
|
||||||
|
t.Error("Expected non-empty JSON string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||||
|
// Create extension with limited network permissions
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
|
privateIPs := []string{
|
||||||
|
"http://localhost/admin",
|
||||||
|
"http://127.0.0.1/admin",
|
||||||
|
"http://192.168.1.1/admin",
|
||||||
|
"http://10.0.0.1/admin",
|
||||||
|
"http://172.16.0.1/admin",
|
||||||
|
"http://169.254.169.254/latest/meta-data/", // AWS metadata
|
||||||
|
"http://router.local/admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, url := range privateIPs {
|
||||||
|
err := runtime.validateDomain(url)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected private IP/host '%s' to be blocked", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||||
|
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPrivateIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"localhost", true},
|
||||||
|
{"127.0.0.1", true},
|
||||||
|
{"127.0.0.2", true},
|
||||||
|
{"10.0.0.1", true},
|
||||||
|
{"10.255.255.255", true},
|
||||||
|
{"172.16.0.1", true},
|
||||||
|
{"172.31.255.255", true},
|
||||||
|
{"192.168.0.1", true},
|
||||||
|
{"192.168.255.255", true},
|
||||||
|
{"169.254.169.254", true},
|
||||||
|
{"router.local", true},
|
||||||
|
{"mydevice.local", true},
|
||||||
|
|
||||||
|
{"8.8.8.8", false},
|
||||||
|
{"1.1.1.1", false},
|
||||||
|
{"api.example.com", false},
|
||||||
|
{"google.com", false},
|
||||||
|
{"172.15.0.1", false},
|
||||||
|
{"172.32.0.1", false},
|
||||||
|
{"192.167.0.1", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
result := isPrivateIP(tt.host)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Package gobackend provides timeout execution for extension JS code
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"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 {
|
||||||
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
val, err := vm.RunString(script)
|
||||||
|
resultCh <- result{val, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case res := <-resultCh:
|
||||||
|
return res.value, res.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
interruptMu.Lock()
|
||||||
|
interrupted = true
|
||||||
|
interruptMu.Unlock()
|
||||||
|
|
||||||
|
vm.Interrupt("execution timeout")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case res := <-resultCh:
|
||||||
|
if res.err != nil {
|
||||||
|
return nil, res.err
|
||||||
|
}
|
||||||
|
return nil, &JSExecutionError{
|
||||||
|
Message: "execution timeout exceeded",
|
||||||
|
IsTimeout: true,
|
||||||
|
}
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
return nil, &JSExecutionError{
|
||||||
|
Message: "execution timeout exceeded (force)",
|
||||||
|
IsTimeout: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
||||||
|
// This should be used when you want to continue using the VM after a timeout
|
||||||
|
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
|
result, err := RunWithTimeout(vm, script, timeout)
|
||||||
|
|
||||||
|
// Clear any interrupt state so VM can be reused
|
||||||
|
vm.ClearInterrupt()
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsTimeoutError(err error) bool {
|
||||||
|
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||||
|
return jsErr.IsTimeout
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -6,28 +6,21 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invalid filename characters for Android/Windows/Linux
|
|
||||||
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||||
|
|
||||||
// sanitizeFilename removes invalid characters from filename
|
|
||||||
func sanitizeFilename(filename string) string {
|
func sanitizeFilename(filename string) string {
|
||||||
// Replace invalid characters with underscore
|
|
||||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||||
|
|
||||||
// Remove leading/trailing spaces and dots
|
|
||||||
sanitized = strings.TrimSpace(sanitized)
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
sanitized = strings.Trim(sanitized, ".")
|
sanitized = strings.Trim(sanitized, ".")
|
||||||
|
|
||||||
// Collapse multiple underscores
|
|
||||||
multiUnderscore := regexp.MustCompile(`_+`)
|
multiUnderscore := regexp.MustCompile(`_+`)
|
||||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||||
|
|
||||||
// Limit length (Android has 255 byte limit for filenames)
|
|
||||||
if len(sanitized) > 200 {
|
if len(sanitized) > 200 {
|
||||||
sanitized = sanitized[:200]
|
sanitized = sanitized[:200]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure not empty
|
|
||||||
if sanitized == "" {
|
if sanitized == "" {
|
||||||
sanitized = "untitled"
|
sanitized = "untitled"
|
||||||
}
|
}
|
||||||
@@ -35,7 +28,6 @@ func sanitizeFilename(filename string) string {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFilenameFromTemplate builds a filename from template and metadata
|
|
||||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||||
if template == "" {
|
if template == "" {
|
||||||
template = "{artist} - {title}"
|
template = "{artist} - {title}"
|
||||||
@@ -43,7 +35,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
|
|
||||||
result := template
|
result := template
|
||||||
|
|
||||||
// Replace placeholders
|
|
||||||
placeholders := map[string]string{
|
placeholders := map[string]string{
|
||||||
"{title}": getString(metadata, "title"),
|
"{title}": getString(metadata, "title"),
|
||||||
"{artist}": getString(metadata, "artist"),
|
"{artist}": getString(metadata, "artist"),
|
||||||
@@ -63,7 +54,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
func getString(m map[string]interface{}, key string) string {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if v, ok := m[key]; ok {
|
if v, ok := m[key]; ok {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
// Trim leading/trailing whitespace to prevent filename issues
|
|
||||||
return strings.TrimSpace(s)
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +88,6 @@ func formatDiscNumber(n int) string {
|
|||||||
return fmt.Sprintf("%d", n)
|
return fmt.Sprintf("%d", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
|
|
||||||
func extractYear(date string) string {
|
func extractYear(date string) string {
|
||||||
if len(date) >= 4 {
|
if len(date) >= 4 {
|
||||||
return date[:4]
|
return date[:4]
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
module github.com/zarz/spotiflac_android/go_backend
|
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 (
|
require (
|
||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac v1.0.0
|
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-20260204172633-1dceadbbeea3
|
||||||
|
golang.org/x/net v0.49.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||||
golang.org/x/mod v0.31.0 // 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.47.0 // indirect
|
||||||
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/tools v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,50 @@
|
|||||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
|
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||||
|
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
|
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
||||||
|
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
||||||
|
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||||
|
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
|
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||||
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||||
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
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/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
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=
|
||||||
|
|||||||
@@ -15,74 +15,28 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP utility functions for consistent request handling across all downloaders
|
|
||||||
|
|
||||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
|
||||||
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
|
||||||
func getRandomUserAgent() string {
|
func getRandomUserAgent() string {
|
||||||
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
chromeVersion := rand.Intn(26) + 120
|
||||||
// Some APIs may block mobile User-Agents, so we use desktop format
|
chromeBuild := rand.Intn(1500) + 6000
|
||||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
chromePatch := rand.Intn(200) + 100
|
||||||
|
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
|
||||||
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
|
||||||
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||||
winMajor,
|
|
||||||
chromeVersion,
|
chromeVersion,
|
||||||
chromeBuild,
|
chromeBuild,
|
||||||
chromePatch,
|
chromePatch,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
|
||||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
|
||||||
func getRandomMacUserAgent() string {
|
|
||||||
macMajor := rand.Intn(4) + 11 // macOS 11-14
|
|
||||||
macMinor := rand.Intn(5) + 4 // Minor 4-8
|
|
||||||
webkitMajor := rand.Intn(7) + 530
|
|
||||||
webkitMinor := rand.Intn(7) + 30
|
|
||||||
chromeMajor := rand.Intn(25) + 80
|
|
||||||
chromeBuild := rand.Intn(1500) + 3000
|
|
||||||
chromePatch := rand.Intn(65) + 60
|
|
||||||
safariMajor := rand.Intn(7) + 530
|
|
||||||
safariMinor := rand.Intn(6) + 30
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
|
||||||
macMajor,
|
|
||||||
macMinor,
|
|
||||||
webkitMajor,
|
|
||||||
webkitMinor,
|
|
||||||
chromeMajor,
|
|
||||||
chromeBuild,
|
|
||||||
chromePatch,
|
|
||||||
safariMajor,
|
|
||||||
safariMinor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
|
||||||
func getRandomDesktopUserAgent() string {
|
|
||||||
if rand.Intn(2) == 0 {
|
|
||||||
return getRandomUserAgent() // Windows
|
|
||||||
}
|
|
||||||
return getRandomMacUserAgent() // Mac
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default timeout values
|
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
DefaultTimeout = 60 * time.Second
|
||||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
DownloadTimeout = 120 * time.Second
|
||||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3 // Default retry count
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
DefaultRetryDelay = 1 * time.Second
|
||||||
|
Second = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
|
||||||
// Optimized for large file downloads (FLAC ~30-50MB)
|
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -94,27 +48,44 @@ var sharedTransport = &http.Transport{
|
|||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
DisableKeepAlives: false,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
WriteBufferSize: 64 * 1024,
|
||||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
ReadBufferSize: 64 * 1024,
|
||||||
DisableCompression: true, // FLAC is already compressed
|
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{
|
var sharedClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DefaultTimeout,
|
Timeout: DefaultTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for downloads (longer timeout, reuses connections)
|
|
||||||
var downloadClient = &http.Client{
|
var downloadClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DownloadTimeout,
|
Timeout: DownloadTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
|
||||||
// Uses shared transport for connection reuse
|
|
||||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
@@ -122,29 +93,33 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSharedClient returns the shared HTTP client for general requests
|
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
|
||||||
|
// Use this for API calls that should not be affected by download traffic.
|
||||||
|
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: metadataTransport,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetSharedClient() *http.Client {
|
func GetSharedClient() *http.Client {
|
||||||
return sharedClient
|
return sharedClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadClient returns the shared HTTP client for downloads
|
|
||||||
func GetDownloadClient() *http.Client {
|
func GetDownloadClient() *http.Client {
|
||||||
return downloadClient
|
return downloadClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseIdleConnections closes idle connections in the shared transport
|
|
||||||
// Call this periodically during large batch downloads to prevent connection buildup
|
|
||||||
func CloseIdleConnections() {
|
func CloseIdleConnections() {
|
||||||
sharedTransport.CloseIdleConnections()
|
sharedTransport.CloseIdleConnections()
|
||||||
|
metadataTransport.CloseIdleConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
|
||||||
// Also checks for ISP blocking on errors
|
// Also checks for ISP blocking on errors
|
||||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check for ISP blocking
|
|
||||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
}
|
}
|
||||||
return resp, err
|
return resp, err
|
||||||
@@ -158,7 +133,6 @@ type RetryConfig struct {
|
|||||||
BackoffFactor float64
|
BackoffFactor float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRetryConfig returns default retry configuration
|
|
||||||
func DefaultRetryConfig() RetryConfig {
|
func DefaultRetryConfig() RetryConfig {
|
||||||
return RetryConfig{
|
return RetryConfig{
|
||||||
MaxRetries: DefaultMaxRetries,
|
MaxRetries: DefaultMaxRetries,
|
||||||
@@ -168,31 +142,25 @@ func DefaultRetryConfig() RetryConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
|
||||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
|
||||||
// Also detects and logs ISP blocking
|
|
||||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
delay := config.InitialDelay
|
delay := config.InitialDelay
|
||||||
requestURL := req.URL.String()
|
requestURL := req.URL.String()
|
||||||
|
|
||||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||||
// Clone request for retry (body needs to be re-readable)
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := client.Do(reqCopy)
|
resp, err := client.Do(reqCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
// Check for ISP blocking on network errors
|
|
||||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||||
// Don't retry if ISP blocking is detected - it won't help
|
|
||||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||||
}
|
}
|
||||||
|
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||||
attempt+1, config.MaxRetries+1, err, delay)
|
attempt+1, config.MaxRetries+1, err, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
@@ -200,12 +168,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle rate limiting (429)
|
|
||||||
if resp.StatusCode == 429 {
|
if resp.StatusCode == 429 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
retryAfter := getRetryAfterDuration(resp)
|
retryAfter := getRetryAfterDuration(resp)
|
||||||
@@ -227,13 +193,13 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
bodyStr := strings.ToLower(string(body))
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
|
||||||
// Check if response looks like ISP blocking page
|
// Check if response looks like ISP blocking page
|
||||||
ispBlockingIndicators := []string{
|
ispBlockingIndicators := []string{
|
||||||
"blocked", "forbidden", "access denied", "not available in your",
|
"blocked", "forbidden", "access denied", "not available in your",
|
||||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, indicator := range ispBlockingIndicators {
|
for _, indicator := range ispBlockingIndicators {
|
||||||
if strings.Contains(bodyStr, indicator) {
|
if strings.Contains(bodyStr, indicator) {
|
||||||
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
||||||
@@ -245,7 +211,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server errors (5xx) - retry
|
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||||
@@ -257,23 +222,17 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client errors (4xx except 429) - don't retry
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
|
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 {
|
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||||
if nextDelay > config.MaxDelay {
|
return min(nextDelay, config.MaxDelay)
|
||||||
nextDelay = config.MaxDelay
|
|
||||||
}
|
|
||||||
return nextDelay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
|
||||||
// Returns 60 seconds as default if header is missing or invalid
|
// Returns 60 seconds as default if header is missing or invalid
|
||||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||||
retryAfter := resp.Header.Get("Retry-After")
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
@@ -281,12 +240,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
return 60 * time.Second // Default wait time
|
return 60 * time.Second // Default wait time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as seconds
|
|
||||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
return time.Duration(seconds) * time.Second
|
return time.Duration(seconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as HTTP date
|
|
||||||
if t, err := http.ParseTime(retryAfter); err == nil {
|
if t, err := http.ParseTime(retryAfter); err == nil {
|
||||||
duration := time.Until(t)
|
duration := time.Until(t)
|
||||||
if duration > 0 {
|
if duration > 0 {
|
||||||
@@ -297,8 +254,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
return 60 * time.Second // Default
|
return 60 * time.Second // Default
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadResponseBody reads and returns the response body
|
|
||||||
// Returns error if body is empty
|
|
||||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
return nil, fmt.Errorf("response is nil")
|
return nil, fmt.Errorf("response is nil")
|
||||||
@@ -316,7 +271,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateResponse checks if response is valid (non-nil, status 2xx)
|
|
||||||
func ValidateResponse(resp *http.Response) error {
|
func ValidateResponse(resp *http.Response) error {
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
return fmt.Errorf("response is nil")
|
return fmt.Errorf("response is nil")
|
||||||
@@ -329,14 +283,12 @@ func ValidateResponse(resp *http.Response) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildErrorMessage creates a detailed error message for API failures
|
|
||||||
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
|
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
|
||||||
msg := fmt.Sprintf("API %s failed", apiURL)
|
msg := fmt.Sprintf("API %s failed", apiURL)
|
||||||
if statusCode > 0 {
|
if statusCode > 0 {
|
||||||
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
|
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
|
||||||
}
|
}
|
||||||
if responsePreview != "" {
|
if responsePreview != "" {
|
||||||
// Truncate preview if too long
|
|
||||||
if len(responsePreview) > 100 {
|
if len(responsePreview) > 100 {
|
||||||
responsePreview = responsePreview[:100] + "..."
|
responsePreview = responsePreview[:100] + "..."
|
||||||
}
|
}
|
||||||
@@ -345,7 +297,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISPBlockingError represents an error caused by ISP blocking
|
|
||||||
type ISPBlockingError struct {
|
type ISPBlockingError struct {
|
||||||
Domain string
|
Domain string
|
||||||
Reason string
|
Reason string
|
||||||
@@ -356,18 +307,14 @@ func (e *ISPBlockingError) Error() string {
|
|||||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
|
||||||
// Returns the ISPBlockingError if detected, nil otherwise
|
|
||||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain from URL
|
|
||||||
domain := extractDomain(requestURL)
|
domain := extractDomain(requestURL)
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
// Check for DNS resolution failure (common ISP blocking method)
|
|
||||||
var dnsErr *net.DNSError
|
var dnsErr *net.DNSError
|
||||||
if errors.As(err, &dnsErr) {
|
if errors.As(err, &dnsErr) {
|
||||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||||
@@ -379,11 +326,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for connection refused (ISP firewall blocking)
|
|
||||||
var opErr *net.OpError
|
var opErr *net.OpError
|
||||||
if errors.As(err, &opErr) {
|
if errors.As(err, &opErr) {
|
||||||
if opErr.Op == "dial" {
|
if opErr.Op == "dial" {
|
||||||
// Check for specific syscall errors
|
|
||||||
var syscallErr syscall.Errno
|
var syscallErr syscall.Errno
|
||||||
if errors.As(opErr.Err, &syscallErr) {
|
if errors.As(opErr.Err, &syscallErr) {
|
||||||
switch syscallErr {
|
switch syscallErr {
|
||||||
@@ -422,7 +367,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
|
||||||
var tlsErr *tls.RecordHeaderError
|
var tlsErr *tls.RecordHeaderError
|
||||||
if errors.As(err, &tlsErr) {
|
if errors.As(err, &tlsErr) {
|
||||||
return &ISPBlockingError{
|
return &ISPBlockingError{
|
||||||
@@ -461,7 +405,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
|
|
||||||
// Returns true if ISP blocking was detected
|
// Returns true if ISP blocking was detected
|
||||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
ispErr := IsISPBlocking(err, requestURL)
|
ispErr := IsISPBlocking(err, requestURL)
|
||||||
@@ -481,10 +424,9 @@ func extractDomain(rawURL string) string {
|
|||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := url.Parse(rawURL)
|
parsed, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try to extract domain manually
|
|
||||||
rawURL = strings.TrimPrefix(rawURL, "https://")
|
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||||
rawURL = strings.TrimPrefix(rawURL, "http://")
|
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||||
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||||
@@ -492,24 +434,23 @@ func extractDomain(rawURL string) string {
|
|||||||
}
|
}
|
||||||
return rawURL
|
return rawURL
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Host != "" {
|
if parsed.Host != "" {
|
||||||
return parsed.Host
|
return parsed.Host
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
|
|
||||||
// If ISP blocking is detected, returns a more descriptive error
|
// If ISP blocking is detected, returns a more descriptive error
|
||||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
||||||
domain := extractDomain(requestURL)
|
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 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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build ios
|
||||||
|
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
|
||||||
|
// Fall back to standard HTTP client
|
||||||
|
|
||||||
|
// GetCloudflareBypassClient returns the standard HTTP client on iOS
|
||||||
|
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
|
||||||
|
func GetCloudflareBypassClient() *http.Client {
|
||||||
|
return sharedClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoRequestWithCloudflareBypass on iOS just uses the standard client
|
||||||
|
// uTLS Chrome fingerprint bypass is not available on iOS
|
||||||
|
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
resp, err := sharedClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//go:build !ios
|
||||||
|
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
utls "github.com/refraction-networking/utls"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
|
||||||
|
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
|
||||||
|
type utlsTransport struct {
|
||||||
|
dialer *net.Dialer
|
||||||
|
mu sync.Mutex
|
||||||
|
h2Transports map[string]*http2.Transport
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUTLSTransport() *utlsTransport {
|
||||||
|
return &utlsTransport{
|
||||||
|
dialer: &net.Dialer{
|
||||||
|
Timeout: 30 * Second,
|
||||||
|
KeepAlive: 30 * Second,
|
||||||
|
},
|
||||||
|
h2Transports: make(map[string]*http2.Transport),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Scheme != "https" {
|
||||||
|
return sharedTransport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := req.URL.Hostname()
|
||||||
|
port := t.getPort(req.URL)
|
||||||
|
addr := net.JoinHostPort(host, port)
|
||||||
|
|
||||||
|
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConn := utls.UClient(conn, &utls.Config{
|
||||||
|
ServerName: host,
|
||||||
|
NextProtos: []string{"h2", "http/1.1"},
|
||||||
|
}, utls.HelloChrome_Auto)
|
||||||
|
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||||
|
|
||||||
|
if negotiatedProto == "h2" {
|
||||||
|
h2Transport := &http2.Transport{
|
||||||
|
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||||||
|
return tlsConn, nil
|
||||||
|
},
|
||||||
|
AllowHTTP: false,
|
||||||
|
DisableCompression: false,
|
||||||
|
}
|
||||||
|
return h2Transport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return tlsConn, nil
|
||||||
|
},
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsTransport) getPort(u *url.URL) string {
|
||||||
|
if u.Port() != "" {
|
||||||
|
return u.Port()
|
||||||
|
}
|
||||||
|
if u.Scheme == "https" {
|
||||||
|
return "443"
|
||||||
|
}
|
||||||
|
return "80"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloudflare bypass client using uTLS Chrome fingerprint
|
||||||
|
var cloudflareBypassTransport = newUTLSTransport()
|
||||||
|
|
||||||
|
var cloudflareBypassClient = &http.Client{
|
||||||
|
Transport: cloudflareBypassTransport,
|
||||||
|
Timeout: DefaultTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
|
||||||
|
// Use this when requests are blocked by Cloudflare (common when using VPN)
|
||||||
|
func GetCloudflareBypassClient() *http.Client {
|
||||||
|
return cloudflareBypassClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoRequestWithCloudflareBypass attempts request with standard client first,
|
||||||
|
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
|
||||||
|
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
|
||||||
|
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
// Try with standard client first
|
||||||
|
resp, err := sharedClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
// Check for Cloudflare challenge page (403 with specific markers)
|
||||||
|
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if readErr == nil {
|
||||||
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
cloudflareMarkers := []string{
|
||||||
|
"cloudflare", "cf-ray", "checking your browser",
|
||||||
|
"please wait", "ddos protection", "ray id",
|
||||||
|
"enable javascript", "challenge-platform",
|
||||||
|
}
|
||||||
|
|
||||||
|
isCloudflare := false
|
||||||
|
for _, marker := range cloudflareMarkers {
|
||||||
|
if strings.Contains(bodyStr, marker) {
|
||||||
|
isCloudflare = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCloudflare {
|
||||||
|
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||||
|
|
||||||
|
// Clone request for retry
|
||||||
|
reqCopy := req.Clone(req.Context())
|
||||||
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
// Retry with uTLS Chrome fingerprint
|
||||||
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not Cloudflare, return original response (recreate body)
|
||||||
|
return &http.Response{
|
||||||
|
Status: resp.Status,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Header: resp.Header,
|
||||||
|
Body: io.NopCloser(strings.NewReader(string(body))),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if error might be TLS-related (Cloudflare blocking)
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||||
|
strings.Contains(errStr, "handshake") ||
|
||||||
|
strings.Contains(errStr, "certificate") ||
|
||||||
|
strings.Contains(errStr, "connection reset")
|
||||||
|
|
||||||
|
if tlsRelated {
|
||||||
|
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||||
|
|
||||||
|
// Clone request for retry
|
||||||
|
reqCopy := req.Clone(req.Context())
|
||||||
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
// Retry with uTLS Chrome fingerprint
|
||||||
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IDHSClient is a client for I Don't Have Spotify API
|
||||||
|
// Used as fallback when SongLink fails or is rate limited
|
||||||
|
type IDHSClient struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalIDHSClient *IDHSClient
|
||||||
|
idhsClientOnce sync.Once
|
||||||
|
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
// IDHSSearchRequest represents the request body for IDHS API
|
||||||
|
type IDHSSearchRequest struct {
|
||||||
|
Link string `json:"link"`
|
||||||
|
Adapters []string `json:"adapters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDHSSearchResponse represents the response from IDHS API
|
||||||
|
type IDHSSearchResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // song, album, artist, podcast, show
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Audio string `json:"audio,omitempty"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
UniversalLink string `json:"universalLink"`
|
||||||
|
Links []IDHSLink `json:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDHSLink represents a link to a streaming platform
|
||||||
|
type IDHSLink struct {
|
||||||
|
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
||||||
|
URL string `json:"url"`
|
||||||
|
IsVerified bool `json:"isVerified,omitempty"`
|
||||||
|
NotAvailable bool `json:"notAvailable,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIDHSClient creates a new IDHS client
|
||||||
|
func NewIDHSClient() *IDHSClient {
|
||||||
|
idhsClientOnce.Do(func() {
|
||||||
|
globalIDHSClient = &IDHSClient{
|
||||||
|
client: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalIDHSClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search converts a music link to links on other platforms
|
||||||
|
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
||||||
|
idhsRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
reqBody := IDHSSearchRequest{
|
||||||
|
Link: link,
|
||||||
|
Adapters: adapters,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("invalid link or missing parameters")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("IDHS rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 500 {
|
||||||
|
return nil, fmt.Errorf("IDHS processing failed")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result IDHSSearchResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
|
||||||
|
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
|
|
||||||
|
// Request only the platforms we need
|
||||||
|
adapters := []string{"tidal", "deezer"}
|
||||||
|
|
||||||
|
result, err := c.Search(spotifyURL, adapters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
SpotifyID: spotifyTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, link := range result.Links {
|
||||||
|
if link.NotAvailable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(link.Type) {
|
||||||
|
case "tidal":
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = link.URL
|
||||||
|
case "deezer":
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = link.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(link.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v",
|
||||||
|
spotifyTrackID, availability.Tidal, availability.Deezer)
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailabilityFromDeezer checks track availability using IDHS
|
||||||
|
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
|
// Request only the platforms we need
|
||||||
|
adapters := []string{"spotify", "tidal"}
|
||||||
|
|
||||||
|
result, err := c.Search(deezerURL, adapters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
Deezer: true,
|
||||||
|
DeezerID: deezerTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, link := range result.Links {
|
||||||
|
if link.NotAvailable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(link.Type) {
|
||||||
|
case "spotify":
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(link.URL)
|
||||||
|
case "tidal":
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = link.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v",
|
||||||
|
deezerTrackID, availability.SpotifyID, availability.Tidal)
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibraryScanResult represents metadata from a scanned audio file
|
||||||
|
type LibraryScanResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TrackName string `json:"trackName"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
CoverPath string `json:"coverPath,omitempty"`
|
||||||
|
ScannedAt string `json:"scannedAt"`
|
||||||
|
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
TrackNumber int `json:"trackNumber,omitempty"`
|
||||||
|
DiscNumber int `json:"discNumber,omitempty"`
|
||||||
|
Duration int `json:"duration,omitempty"`
|
||||||
|
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||||
|
BitDepth int `json:"bitDepth,omitempty"`
|
||||||
|
SampleRate int `json:"sampleRate,omitempty"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryScanProgress struct {
|
||||||
|
TotalFiles int `json:"total_files"`
|
||||||
|
ScannedFiles int `json:"scanned_files"`
|
||||||
|
CurrentFile string `json:"current_file"`
|
||||||
|
ErrorCount int `json:"error_count"`
|
||||||
|
ProgressPct float64 `json:"progress_pct"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementalScanResult contains results of an incremental library scan
|
||||||
|
type IncrementalScanResult struct {
|
||||||
|
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
||||||
|
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
||||||
|
SkippedCount int `json:"skippedCount"` // Files that were unchanged
|
||||||
|
TotalFiles int `json:"totalFiles"` // Total files in folder
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
libraryScanProgress LibraryScanProgress
|
||||||
|
libraryScanProgressMu sync.RWMutex
|
||||||
|
libraryScanCancel chan struct{}
|
||||||
|
libraryScanCancelMu sync.Mutex
|
||||||
|
libraryCoverCacheDir string
|
||||||
|
libraryCoverCacheMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedAudioFormats = map[string]bool{
|
||||||
|
".flac": true,
|
||||||
|
".m4a": true,
|
||||||
|
".mp3": true,
|
||||||
|
".opus": true,
|
||||||
|
".ogg": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||||
|
libraryCoverCacheMu.Lock()
|
||||||
|
libraryCoverCacheDir = cacheDir
|
||||||
|
libraryCoverCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanLibraryFolder(folderPath string) (string, error) {
|
||||||
|
if folderPath == "" {
|
||||||
|
return "[]", fmt.Errorf("folder path is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", fmt.Errorf("folder not found: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return "[]", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress = LibraryScanProgress{}
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
libraryScanCancelMu.Lock()
|
||||||
|
if libraryScanCancel != nil {
|
||||||
|
close(libraryScanCancel)
|
||||||
|
}
|
||||||
|
libraryScanCancel = make(chan struct{})
|
||||||
|
cancelCh := libraryScanCancel
|
||||||
|
libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
|
var audioFiles []string
|
||||||
|
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsDir() {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if supportedAudioFormats[ext] {
|
||||||
|
audioFiles = append(audioFiles, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFiles := len(audioFiles)
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
if totalFiles == 0 {
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
return "[]", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
|
||||||
|
|
||||||
|
results := make([]LibraryScanResult, 0, totalFiles)
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
for i, filePath := range audioFiles {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return "[]", fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ScannedFiles = i + 1
|
||||||
|
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
||||||
|
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
result, err := scanAudioFile(filePath, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, *result)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ErrorCount = errorCount
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount)
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
|
result := &LibraryScanResult{
|
||||||
|
ID: generateLibraryID(filePath),
|
||||||
|
FilePath: filePath,
|
||||||
|
ScannedAt: scanTime,
|
||||||
|
Format: strings.TrimPrefix(ext, "."),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file modification time
|
||||||
|
if info, err := os.Stat(filePath); err == nil {
|
||||||
|
result.FileModTime = info.ModTime().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryCoverCacheMu.RLock()
|
||||||
|
coverCacheDir := libraryCoverCacheDir
|
||||||
|
libraryCoverCacheMu.RUnlock()
|
||||||
|
if coverCacheDir != "" && ext != ".m4a" {
|
||||||
|
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
||||||
|
if err == nil && coverPath != "" {
|
||||||
|
result.CoverPath = coverPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".flac":
|
||||||
|
return scanFLACFile(filePath, result)
|
||||||
|
case ".m4a":
|
||||||
|
return scanM4AFile(filePath, result)
|
||||||
|
case ".mp3":
|
||||||
|
return scanMP3File(filePath, result)
|
||||||
|
case ".opus", ".ogg":
|
||||||
|
return scanOggFile(filePath, result)
|
||||||
|
default:
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadMetadata(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
|
||||||
|
quality, err := GetAudioQuality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
|
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.TrackName == "" {
|
||||||
|
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
}
|
||||||
|
if result.ArtistName == "" {
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
if result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
quality, err := GetM4AQuality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadID3Tags(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
if metadata.Date != "" {
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
} else {
|
||||||
|
result.ReleaseDate = metadata.Year
|
||||||
|
}
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
|
||||||
|
quality, err := GetMP3Quality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.Duration = quality.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.TrackName == "" {
|
||||||
|
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
}
|
||||||
|
if result.ArtistName == "" {
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
if result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadOggVorbisComments(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
|
||||||
|
quality, err := GetOggQuality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.Duration = quality.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.TrackName == "" {
|
||||||
|
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
}
|
||||||
|
if result.ArtistName == "" {
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
if result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
|
||||||
|
parts := strings.SplitN(filename, " - ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
|
||||||
|
result.TrackName = parts[1]
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
} else {
|
||||||
|
result.ArtistName = parts[0]
|
||||||
|
result.TrackName = parts[1]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(filename) > 3 && isNumeric(filename[:2]) {
|
||||||
|
title := strings.TrimLeft(filename[2:], " .-")
|
||||||
|
result.TrackName = title
|
||||||
|
} else {
|
||||||
|
result.TrackName = filename
|
||||||
|
}
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(filePath)
|
||||||
|
result.AlbumName = filepath.Base(dir)
|
||||||
|
if result.AlbumName == "." || result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNumeric(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateLibraryID(filePath string) string {
|
||||||
|
return fmt.Sprintf("lib_%x", hashString(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashString(s string) uint32 {
|
||||||
|
var hash uint32 = 5381
|
||||||
|
for _, c := range s {
|
||||||
|
hash = ((hash << 5) + hash) + uint32(c)
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLibraryScanProgress() string {
|
||||||
|
libraryScanProgressMu.RLock()
|
||||||
|
defer libraryScanProgressMu.RUnlock()
|
||||||
|
|
||||||
|
jsonBytes, _ := json.Marshal(libraryScanProgress)
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CancelLibraryScan() {
|
||||||
|
libraryScanCancelMu.Lock()
|
||||||
|
defer libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
|
if libraryScanCancel != nil {
|
||||||
|
close(libraryScanCancel)
|
||||||
|
libraryScanCancel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadata(filePath string) (string, error) {
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
result, err := scanAudioFile(filePath, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||||
|
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||||
|
// Only files that are new or have changed modification time will be scanned
|
||||||
|
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||||
|
if folderPath == "" {
|
||||||
|
return "{}", fmt.Errorf("folder path is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", fmt.Errorf("folder not found: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing files map
|
||||||
|
existingFiles := make(map[string]int64)
|
||||||
|
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||||
|
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||||
|
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||||
|
|
||||||
|
// Reset progress
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress = LibraryScanProgress{}
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
// Setup cancellation
|
||||||
|
libraryScanCancelMu.Lock()
|
||||||
|
if libraryScanCancel != nil {
|
||||||
|
close(libraryScanCancel)
|
||||||
|
}
|
||||||
|
libraryScanCancel = make(chan struct{})
|
||||||
|
cancelCh := libraryScanCancel
|
||||||
|
libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
|
// Collect all audio files with their mod times
|
||||||
|
type fileInfo struct {
|
||||||
|
path string
|
||||||
|
modTime int64
|
||||||
|
}
|
||||||
|
var currentFiles []fileInfo
|
||||||
|
currentPathSet := make(map[string]bool)
|
||||||
|
|
||||||
|
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsDir() {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if supportedAudioFormats[ext] {
|
||||||
|
currentFiles = append(currentFiles, fileInfo{
|
||||||
|
path: path,
|
||||||
|
modTime: info.ModTime().UnixMilli(),
|
||||||
|
})
|
||||||
|
currentPathSet[path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "{}", err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFiles := len(currentFiles)
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
// Find files to scan (new or modified)
|
||||||
|
var filesToScan []fileInfo
|
||||||
|
skippedCount := 0
|
||||||
|
|
||||||
|
for _, f := range currentFiles {
|
||||||
|
existingModTime, exists := existingFiles[f.path]
|
||||||
|
if !exists {
|
||||||
|
// New file
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
} else if f.modTime != existingModTime {
|
||||||
|
// Modified file
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
} else {
|
||||||
|
// Unchanged file - skip
|
||||||
|
skippedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find deleted files
|
||||||
|
var deletedPaths []string
|
||||||
|
for existingPath := range existingFiles {
|
||||||
|
if !currentPathSet[existingPath] {
|
||||||
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
|
||||||
|
len(filesToScan), skippedCount, len(deletedPaths))
|
||||||
|
|
||||||
|
if len(filesToScan) == 0 {
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ScannedFiles = totalFiles
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgress.ProgressPct = 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
result := IncrementalScanResult{
|
||||||
|
Scanned: []LibraryScanResult{},
|
||||||
|
DeletedPaths: deletedPaths,
|
||||||
|
SkippedCount: skippedCount,
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.Marshal(result)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the files that need scanning
|
||||||
|
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
for i, f := range filesToScan {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return "{}", fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ScannedFiles = skippedCount + i + 1
|
||||||
|
libraryScanProgress.CurrentFile = filepath.Base(f.path)
|
||||||
|
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
result, err := scanAudioFile(f.path, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, *result)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ErrorCount = errorCount
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgress.ScannedFiles = totalFiles
|
||||||
|
libraryScanProgress.ProgressPct = 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
|
||||||
|
len(results), skippedCount, len(deletedPaths), errorCount)
|
||||||
|
|
||||||
|
scanResult := IncrementalScanResult{
|
||||||
|
Scanned: results,
|
||||||
|
DeletedPaths: deletedPaths,
|
||||||
|
SkippedCount: skippedCount,
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(scanResult)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogEntry represents a single log entry
|
|
||||||
type LogEntry struct {
|
type LogEntry struct {
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
@@ -16,55 +15,64 @@ type LogEntry struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
|
|
||||||
type LogBuffer struct {
|
type LogBuffer struct {
|
||||||
entries []LogEntry
|
entries []LogEntry
|
||||||
maxSize int
|
maxSize int
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
|
loggingEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultLogBufferSize = 500
|
||||||
|
maxLogMessageLength = 500
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalLogBuffer *LogBuffer
|
globalLogBuffer *LogBuffer
|
||||||
logBufferOnce sync.Once
|
logBufferOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetLogBuffer returns the singleton log buffer instance
|
|
||||||
func GetLogBuffer() *LogBuffer {
|
func GetLogBuffer() *LogBuffer {
|
||||||
logBufferOnce.Do(func() {
|
logBufferOnce.Do(func() {
|
||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
entries: make([]LogEntry, 0, 500),
|
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||||
maxSize: 500,
|
maxSize: defaultLogBufferSize,
|
||||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalLogBuffer
|
return globalLogBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLoggingEnabled enables or disables logging
|
func truncateLogMessage(message string) string {
|
||||||
|
runes := []rune(message)
|
||||||
|
if len(runes) <= maxLogMessageLength {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
lb.loggingEnabled = enabled
|
lb.loggingEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsLoggingEnabled returns whether logging is enabled
|
|
||||||
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
return lb.loggingEnabled
|
return lb.loggingEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds a log entry to the buffer
|
|
||||||
func (lb *LogBuffer) Add(level, tag, message string) {
|
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
|
|
||||||
// Skip if logging is disabled (except for errors which are always logged)
|
|
||||||
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message = truncateLogMessage(message)
|
||||||
|
|
||||||
entry := LogEntry{
|
entry := LogEntry{
|
||||||
Timestamp: time.Now().Format("15:04:05.000"),
|
Timestamp: time.Now().Format("15:04:05.000"),
|
||||||
Level: level,
|
Level: level,
|
||||||
@@ -73,16 +81,13 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(lb.entries) >= lb.maxSize {
|
if len(lb.entries) >= lb.maxSize {
|
||||||
// Remove oldest entry
|
|
||||||
lb.entries = lb.entries[1:]
|
lb.entries = lb.entries[1:]
|
||||||
}
|
}
|
||||||
lb.entries = append(lb.entries, entry)
|
lb.entries = append(lb.entries, entry)
|
||||||
|
|
||||||
// Also print to logcat for debugging
|
|
||||||
fmt.Printf("[%s] %s\n", tag, message)
|
fmt.Printf("[%s] %s\n", tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll returns all log entries as JSON
|
|
||||||
func (lb *LogBuffer) GetAll() string {
|
func (lb *LogBuffer) GetAll() string {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
@@ -91,7 +96,6 @@ func (lb *LogBuffer) GetAll() string {
|
|||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSince returns log entries since the given index (internal use)
|
|
||||||
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
@@ -107,21 +111,18 @@ func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
|||||||
return entries, len(lb.entries)
|
return entries, len(lb.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear clears all log entries
|
|
||||||
func (lb *LogBuffer) Clear() {
|
func (lb *LogBuffer) Clear() {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
lb.entries = lb.entries[:0]
|
lb.entries = lb.entries[:0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the number of log entries
|
|
||||||
func (lb *LogBuffer) Count() int {
|
func (lb *LogBuffer) Count() int {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
return len(lb.entries)
|
return len(lb.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for logging with different levels
|
|
||||||
func LogDebug(tag, format string, args ...interface{}) {
|
func LogDebug(tag, format string, args ...interface{}) {
|
||||||
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
@@ -158,11 +159,11 @@ func GoLog(format string, args ...interface{}) {
|
|||||||
|
|
||||||
// Determine level from message content
|
// Determine level from message content
|
||||||
msgLower := strings.ToLower(message)
|
msgLower := strings.ToLower(message)
|
||||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
||||||
level = "ERROR"
|
level = "ERROR"
|
||||||
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||||
level = "WARN"
|
level = "WARN"
|
||||||
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
} else if strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||||
level = "INFO"
|
level = "INFO"
|
||||||
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||||
level = "DEBUG"
|
level = "DEBUG"
|
||||||
@@ -171,15 +172,10 @@ func GoLog(format string, args ...interface{}) {
|
|||||||
GetLogBuffer().Add(level, tag, message)
|
GetLogBuffer().Add(level, tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported functions for Flutter
|
|
||||||
|
|
||||||
// GetLogs returns all logs as JSON array
|
|
||||||
func GetLogs() string {
|
func GetLogs() string {
|
||||||
return GetLogBuffer().GetAll()
|
return GetLogBuffer().GetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogsSince returns logs since the given index
|
|
||||||
// Returns JSON: {"logs": [...], "next_index": N}
|
|
||||||
func GetLogsSince(index int) string {
|
func GetLogsSince(index int) string {
|
||||||
entries, nextIndex := GetLogBuffer().getSince(index)
|
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||||
logsJson, _ := json.Marshal(entries)
|
logsJson, _ := json.Marshal(entries)
|
||||||
@@ -187,17 +183,14 @@ func GetLogsSince(index int) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearLogs clears all logs
|
|
||||||
func ClearLogs() {
|
func ClearLogs() {
|
||||||
GetLogBuffer().Clear()
|
GetLogBuffer().Clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogCount returns the number of log entries
|
|
||||||
func GetLogCount() int {
|
func GetLogCount() int {
|
||||||
return GetLogBuffer().Count()
|
return GetLogBuffer().Count()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLoggingEnabled enables or disables logging from Flutter
|
|
||||||
func SetLoggingEnabled(enabled bool) {
|
func SetLoggingEnabled(enabled bool) {
|
||||||
GetLogBuffer().SetLoggingEnabled(enabled)
|
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,93 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lyricsCacheTTL = 24 * time.Hour
|
||||||
|
durationToleranceSec = 10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
type lyricsCacheEntry struct {
|
||||||
|
response *LyricsResponse
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]*lyricsCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalLyricsCache = &lyricsCache{
|
||||||
|
cache: make(map[string]*lyricsCacheEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
|
||||||
|
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
|
||||||
|
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
|
||||||
|
roundedDuration := math.Round(durationSec/10) * 10
|
||||||
|
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
key := c.generateKey(artist, track, durationSec)
|
||||||
|
entry, exists := c.cache[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(entry.expiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.response, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
key := c.generateKey(artist, track, durationSec)
|
||||||
|
c.cache[key] = &lyricsCacheEntry{
|
||||||
|
response: response,
|
||||||
|
expiresAt: time.Now().Add(lyricsCacheTTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) CleanExpired() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cleaned := 0
|
||||||
|
for key, entry := range c.cache {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(c.cache, key)
|
||||||
|
cleaned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Size() int {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return len(c.cache)
|
||||||
|
}
|
||||||
|
|
||||||
type LRCLibResponse struct {
|
type LRCLibResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -44,9 +123,7 @@ type LyricsClient struct {
|
|||||||
|
|
||||||
func NewLyricsClient() *LyricsClient {
|
func NewLyricsClient() *LyricsClient {
|
||||||
return &LyricsClient{
|
return &LyricsClient{
|
||||||
httpClient: &http.Client{
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
Timeout: 15 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +163,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
|||||||
return c.parseLRCLibResponse(&lrcResp), nil
|
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"
|
baseURL := "https://lrclib.net/api/search"
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("q", query)
|
params.Set("q", query)
|
||||||
@@ -118,6 +195,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
|||||||
return nil, fmt.Errorf("no lyrics found")
|
return nil, fmt.Errorf("no lyrics found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bestMatch := c.findBestMatch(results, durationSec)
|
||||||
|
if bestMatch != nil {
|
||||||
|
return c.parseLRCLibResponse(bestMatch), nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
if result.SyncedLyrics != "" {
|
if result.SyncedLyrics != "" {
|
||||||
return c.parseLRCLibResponse(&result), nil
|
return c.parseLRCLibResponse(&result), nil
|
||||||
@@ -127,38 +209,92 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
|||||||
return c.parseLRCLibResponse(&results[0]), nil
|
return c.parseLRCLibResponse(&results[0]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
|
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||||
// Strategy 1: Direct match with artist and track name
|
var bestSynced *LRCLibResponse
|
||||||
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
|
var bestPlain *LRCLibResponse
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
|
||||||
|
for i := range results {
|
||||||
|
result := &results[i]
|
||||||
|
|
||||||
|
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
|
||||||
|
|
||||||
|
if durationMatches {
|
||||||
|
if result.SyncedLyrics != "" && bestSynced == nil {
|
||||||
|
bestSynced = result
|
||||||
|
} else if result.PlainLyrics != "" && bestPlain == nil {
|
||||||
|
bestPlain = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestSynced != nil {
|
||||||
|
return bestSynced
|
||||||
|
}
|
||||||
|
return bestPlain
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||||
|
diff := math.Abs(lrcDuration - targetDuration)
|
||||||
|
return diff <= durationToleranceSec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
|
primaryArtist := normalizeArtistName(artistName)
|
||||||
|
|
||||||
|
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||||
|
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||||
|
cachedCopy := *cached
|
||||||
|
cachedCopy.Source = cached.Source + " (cached)"
|
||||||
|
return &cachedCopy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyrics *LyricsResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
isValidResult := func(l *LyricsResponse) bool {
|
||||||
|
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Try with simplified track name
|
if primaryArtist != artistName {
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
if simplifiedTrack != trackName {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
lyrics.Source = "LRCLIB"
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
lyrics.Source = "LRCLIB (simplified)"
|
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Search with full query
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
query := artistName + " " + trackName
|
if simplifiedTrack != trackName {
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && isValidResult(lyrics) {
|
||||||
|
lyrics.Source = "LRCLIB (simplified)"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := primaryArtist + " " + trackName
|
||||||
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB Search"
|
lyrics.Source = "LRCLIB Search"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 4: Search with simplified query
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = artistName + " " + simplifiedTrack
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB Search (simplified)"
|
lyrics.Source = "LRCLIB Search (simplified)"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,34 +384,6 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
|
||||||
// Use convertToLRCWithMetadata for full LRC with headers
|
|
||||||
func convertToLRC(lyrics *LyricsResponse) string {
|
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var builder strings.Builder
|
|
||||||
|
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
|
||||||
for _, line := range lyrics.Lines {
|
|
||||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
|
||||||
builder.WriteString(timestamp)
|
|
||||||
builder.WriteString(line.Words)
|
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, line := range lyrics.Lines {
|
|
||||||
builder.WriteString(line.Words)
|
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
|
||||||
// Includes [ti:], [ar:], [by:] headers
|
|
||||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -283,13 +391,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
|||||||
|
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
|
|
||||||
// Add metadata headers
|
|
||||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
|
|
||||||
// Add lyrics lines
|
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
for _, line := range lyrics.Lines {
|
for _, line := range lyrics.Lines {
|
||||||
if line.Words == "" {
|
if line.Words == "" {
|
||||||
@@ -338,3 +444,36 @@ func simplifyTrackName(name string) string {
|
|||||||
|
|
||||||
return strings.TrimSpace(result)
|
return strings.TrimSpace(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeArtistName(name string) string {
|
||||||
|
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
|
||||||
|
|
||||||
|
result := name
|
||||||
|
for _, sep := range separators {
|
||||||
|
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
|
||||||
|
result = result[:idx]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||||
|
if lrcContent == "" {
|
||||||
|
return "", fmt.Errorf("empty LRC content")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(audioFilePath)
|
||||||
|
ext := filepath.Ext(audioFilePath)
|
||||||
|
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
|
||||||
|
|
||||||
|
lrcFilePath := filepath.Join(dir, baseName+".lrc")
|
||||||
|
|
||||||
|
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write LRC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath)
|
||||||
|
return lrcFilePath, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture/v2"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis/v2"
|
||||||
"github.com/go-flac/go-flac"
|
"github.com/go-flac/go-flac/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata represents track metadata for embedding
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
@@ -24,16 +26,17 @@ type Metadata struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
Description string
|
Description string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
|
Genre string
|
||||||
|
Label string
|
||||||
|
Copyright string
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedMetadata embeds metadata into a FLAC file
|
|
||||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create vorbis comment block
|
|
||||||
var cmtIdx int = -1
|
var cmtIdx int = -1
|
||||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
@@ -52,7 +55,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
cmt = flacvorbis.New()
|
cmt = flacvorbis.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set metadata fields
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setComment(cmt, "ARTIST", metadata.Artist)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
@@ -84,7 +86,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or add vorbis comment block
|
if metadata.Genre != "" {
|
||||||
|
setComment(cmt, "GENRE", metadata.Genre)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Label != "" {
|
||||||
|
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Copyright != "" {
|
||||||
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -92,14 +105,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
f.Meta = append(f.Meta, &cmtBlock)
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cover art if provided
|
|
||||||
if coverPath != "" {
|
if coverPath != "" {
|
||||||
if fileExists(coverPath) {
|
if fileExists(coverPath) {
|
||||||
coverData, err := os.ReadFile(coverPath)
|
coverData, err := os.ReadFile(coverPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
||||||
} else {
|
} else {
|
||||||
// Remove existing picture blocks first (like PC version)
|
|
||||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||||
if f.Meta[i].Type == flac.Picture {
|
if f.Meta[i].Type == flac.Picture {
|
||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
@@ -125,19 +136,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes
|
|
||||||
// This avoids file permission issues on Android by not requiring a temp file
|
|
||||||
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
|
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create vorbis comment block
|
|
||||||
var cmtIdx int = -1
|
var cmtIdx int = -1
|
||||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
@@ -156,7 +163,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
cmt = flacvorbis.New()
|
cmt = flacvorbis.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set metadata fields
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setComment(cmt, "ARTIST", metadata.Artist)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
@@ -188,7 +194,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or add vorbis comment block
|
if metadata.Genre != "" {
|
||||||
|
setComment(cmt, "GENRE", metadata.Genre)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Label != "" {
|
||||||
|
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Copyright != "" {
|
||||||
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -196,9 +213,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
f.Meta = append(f.Meta, &cmtBlock)
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cover art if provided
|
|
||||||
if len(coverData) > 0 {
|
if len(coverData) > 0 {
|
||||||
// Remove existing picture blocks first
|
|
||||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||||
if f.Meta[i].Type == flac.Picture {
|
if f.Meta[i].Type == flac.Picture {
|
||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
@@ -220,11 +235,9 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadMetadata reads metadata from a FLAC file
|
|
||||||
func ReadMetadata(filePath string) (*Metadata, error) {
|
func ReadMetadata(filePath string) (*Metadata, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -257,7 +270,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
}
|
}
|
||||||
// Also try lowercase variant (some encoders use lowercase)
|
|
||||||
if metadata.TrackNumber == 0 {
|
if metadata.TrackNumber == 0 {
|
||||||
trackNum = getComment(cmt, "TRACK")
|
trackNum = getComment(cmt, "TRACK")
|
||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
@@ -269,7 +281,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
}
|
}
|
||||||
// Also try DISC variant
|
|
||||||
if metadata.DiscNumber == 0 {
|
if metadata.DiscNumber == 0 {
|
||||||
discNum = getComment(cmt, "DISC")
|
discNum = getComment(cmt, "DISC")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
@@ -277,7 +288,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try DATE variants
|
|
||||||
if metadata.Date == "" {
|
if metadata.Date == "" {
|
||||||
metadata.Date = getComment(cmt, "YEAR")
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
}
|
}
|
||||||
@@ -293,7 +303,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing (case-insensitive comparison for Vorbis comments)
|
|
||||||
keyUpper := strings.ToUpper(key)
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
comment := cmt.Comments[i]
|
comment := cmt.Comments[i]
|
||||||
@@ -305,7 +314,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add new
|
|
||||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +321,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
|||||||
keyUpper := strings.ToUpper(key) + "="
|
keyUpper := strings.ToUpper(key) + "="
|
||||||
for _, comment := range cmt.Comments {
|
for _, comment := range cmt.Comments {
|
||||||
if len(comment) > len(key) {
|
if len(comment) > len(key) {
|
||||||
// Case-insensitive comparison for Vorbis comments
|
|
||||||
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||||
if commentUpper == keyUpper {
|
if commentUpper == keyUpper {
|
||||||
return comment[len(key)+1:]
|
return comment[len(key)+1:]
|
||||||
@@ -323,13 +330,44 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileExists checks if a file exists
|
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation
|
func ExtractCoverArt(filePath string) ([]byte, error) {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.Picture {
|
||||||
|
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
|
||||||
|
return pic.ImageData, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.Picture {
|
||||||
|
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(pic.ImageData) > 0 {
|
||||||
|
return pic.ImageData, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no cover art found in file")
|
||||||
|
}
|
||||||
|
|
||||||
func EmbedLyrics(filePath string, lyrics string) error {
|
func EmbedLyrics(filePath string, lyrics string) error {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -367,7 +405,51 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
|||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||||
|
if genre == "" && label == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmtIdx int = -1
|
||||||
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
|
for idx, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.VorbisComment {
|
||||||
|
cmtIdx = idx
|
||||||
|
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmt == nil {
|
||||||
|
cmt = flacvorbis.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
if genre != "" {
|
||||||
|
setComment(cmt, "GENRE", genre)
|
||||||
|
}
|
||||||
|
if label != "" {
|
||||||
|
setComment(cmt, "ORGANIZATION", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmtBlock := cmt.Marshal()
|
||||||
|
if cmtIdx >= 0 {
|
||||||
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
|
} else {
|
||||||
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Save(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -381,13 +463,11 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try LYRICS tag first
|
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to UNSYNCEDLYRICS
|
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
@@ -398,16 +478,12 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
return "", fmt.Errorf("no lyrics found in file")
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioQuality represents audio quality info from a FLAC file
|
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
TotalSamples int64 `json:"total_samples"`
|
TotalSamples int64 `json:"total_samples"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
|
||||||
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
|
||||||
// For M4A files, it delegates to GetM4AQuality
|
|
||||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -415,16 +491,12 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Read first 4 bytes to detect file type
|
|
||||||
marker := make([]byte, 4)
|
marker := make([]byte, 4)
|
||||||
if _, err := file.Read(marker); err != nil {
|
if _, err := file.Read(marker); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a FLAC file
|
|
||||||
if string(marker) == "fLaC" {
|
if string(marker) == "fLaC" {
|
||||||
// Continue reading FLAC metadata
|
|
||||||
// Read metadata block header (4 bytes)
|
|
||||||
header := make([]byte, 4)
|
header := make([]byte, 4)
|
||||||
if _, err := file.Read(header); err != nil {
|
if _, err := file.Read(header); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
@@ -435,19 +507,15 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read STREAMINFO block (34 bytes minimum)
|
|
||||||
streamInfo := make([]byte, 34)
|
streamInfo := make([]byte, 34)
|
||||||
if _, err := file.Read(streamInfo); err != nil {
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse sample rate (20 bits starting at byte 10)
|
|
||||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||||
|
|
||||||
// Parse bits per sample (5 bits)
|
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||||
|
|
||||||
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
|
|
||||||
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||||
int64(streamInfo[14])<<24 |
|
int64(streamInfo[14])<<24 |
|
||||||
int64(streamInfo[15])<<16 |
|
int64(streamInfo[15])<<16 |
|
||||||
@@ -461,362 +529,194 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
file.Seek(0, 0)
|
||||||
// First 4 bytes are size, next 4 should be "ftyp"
|
|
||||||
file.Seek(0, 0) // Reset to beginning
|
|
||||||
header8 := make([]byte, 8)
|
header8 := make([]byte, 8)
|
||||||
if _, err := file.Read(header8); err != nil {
|
if _, err := file.Read(header8); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(header8[4:8]) == "ftyp" {
|
if string(header8[4:8]) == "ftyp" {
|
||||||
// It's an M4A/MP4 file, use M4A quality reader
|
file.Close()
|
||||||
file.Close() // Close before calling GetM4AQuality which opens the file again
|
|
||||||
return GetM4AQuality(filePath)
|
return GetM4AQuality(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// M4A (MP4/AAC) Metadata Embedding
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
|
||||||
// This is a simplified implementation that writes metadata to the file
|
|
||||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
|
||||||
// Read the entire file
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read M4A file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find moov atom position
|
|
||||||
moovPos := findAtom(data, "moov", 0)
|
|
||||||
if moovPos < 0 {
|
|
||||||
return fmt.Errorf("moov atom not found in M4A file")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find udta atom inside moov, or create one
|
|
||||||
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
|
|
||||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
|
||||||
|
|
||||||
// Build new metadata atoms
|
|
||||||
metaAtom := buildMetaAtom(metadata, coverData)
|
|
||||||
|
|
||||||
var newData []byte
|
|
||||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
|
||||||
// udta exists, find meta inside it or replace
|
|
||||||
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
|
|
||||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
|
||||||
|
|
||||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
|
||||||
// Replace existing meta atom
|
|
||||||
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
|
|
||||||
newData = append(newData, data[:metaPos]...)
|
|
||||||
newData = append(newData, metaAtom...)
|
|
||||||
newData = append(newData, data[metaPos+metaSize:]...)
|
|
||||||
} else {
|
|
||||||
// Add meta atom to udta
|
|
||||||
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
|
||||||
newUdtaSize := 8 + len(newUdtaContent)
|
|
||||||
newUdta := make([]byte, 4)
|
|
||||||
newUdta[0] = byte(newUdtaSize >> 24)
|
|
||||||
newUdta[1] = byte(newUdtaSize >> 16)
|
|
||||||
newUdta[2] = byte(newUdtaSize >> 8)
|
|
||||||
newUdta[3] = byte(newUdtaSize)
|
|
||||||
newUdta = append(newUdta, []byte("udta")...)
|
|
||||||
newUdta = append(newUdta, newUdtaContent...)
|
|
||||||
|
|
||||||
newData = append(newData, data[:udtaPos]...)
|
|
||||||
newData = append(newData, newUdta...)
|
|
||||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create new udta with meta
|
|
||||||
udtaContent := metaAtom
|
|
||||||
udtaSize := 8 + len(udtaContent)
|
|
||||||
newUdta := make([]byte, 4)
|
|
||||||
newUdta[0] = byte(udtaSize >> 24)
|
|
||||||
newUdta[1] = byte(udtaSize >> 16)
|
|
||||||
newUdta[2] = byte(udtaSize >> 8)
|
|
||||||
newUdta[3] = byte(udtaSize)
|
|
||||||
newUdta = append(newUdta, []byte("udta")...)
|
|
||||||
newUdta = append(newUdta, udtaContent...)
|
|
||||||
|
|
||||||
// Insert udta at end of moov
|
|
||||||
insertPos := moovPos + moovSize
|
|
||||||
newData = append(newData, data[:insertPos]...)
|
|
||||||
newData = append(newData, newUdta...)
|
|
||||||
newData = append(newData, data[insertPos:]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update moov size
|
|
||||||
newMoovSize := moovSize + len(newData) - len(data)
|
|
||||||
newData[moovPos] = byte(newMoovSize >> 24)
|
|
||||||
newData[moovPos+1] = byte(newMoovSize >> 16)
|
|
||||||
newData[moovPos+2] = byte(newMoovSize >> 8)
|
|
||||||
newData[moovPos+3] = byte(newMoovSize)
|
|
||||||
|
|
||||||
// Write back to file
|
|
||||||
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write M4A file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// findAtom finds an atom by name starting from offset
|
|
||||||
func findAtom(data []byte, name string, offset int) int {
|
|
||||||
for i := offset; i < len(data)-8; {
|
|
||||||
size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
|
|
||||||
if size < 8 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
atomName := string(data[i+4 : i+8])
|
|
||||||
if atomName == name {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
i += size
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
|
||||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|
||||||
// Build ilst content
|
|
||||||
var ilst []byte
|
|
||||||
|
|
||||||
// ©nam - Title
|
|
||||||
if metadata.Title != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ©ART - Artist
|
|
||||||
if metadata.Artist != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ©alb - Album
|
|
||||||
if metadata.Album != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// aART - Album Artist
|
|
||||||
if metadata.AlbumArtist != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ©day - Year/Date
|
|
||||||
if metadata.Date != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// trkn - Track Number
|
|
||||||
if metadata.TrackNumber > 0 {
|
|
||||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// disk - Disc Number
|
|
||||||
if metadata.DiscNumber > 0 {
|
|
||||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ©lyr - Lyrics
|
|
||||||
if metadata.Lyrics != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// covr - Cover Art
|
|
||||||
if len(coverData) > 0 {
|
|
||||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build ilst atom
|
|
||||||
ilstSize := 8 + len(ilst)
|
|
||||||
ilstAtom := make([]byte, 4)
|
|
||||||
ilstAtom[0] = byte(ilstSize >> 24)
|
|
||||||
ilstAtom[1] = byte(ilstSize >> 16)
|
|
||||||
ilstAtom[2] = byte(ilstSize >> 8)
|
|
||||||
ilstAtom[3] = byte(ilstSize)
|
|
||||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
|
||||||
ilstAtom = append(ilstAtom, ilst...)
|
|
||||||
|
|
||||||
// Build hdlr atom (required for meta)
|
|
||||||
hdlr := []byte{
|
|
||||||
0, 0, 0, 33, // size = 33
|
|
||||||
'h', 'd', 'l', 'r',
|
|
||||||
0, 0, 0, 0, // version + flags
|
|
||||||
0, 0, 0, 0, // predefined
|
|
||||||
'm', 'd', 'i', 'r', // handler type
|
|
||||||
'a', 'p', 'p', 'l', // manufacturer
|
|
||||||
0, 0, 0, 0, // component flags
|
|
||||||
0, 0, 0, 0, // component flags mask
|
|
||||||
0, // null terminator
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build meta atom
|
|
||||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
|
||||||
metaContent = append(metaContent, ilstAtom...)
|
|
||||||
|
|
||||||
metaSize := 8 + len(metaContent)
|
|
||||||
metaAtom := make([]byte, 4)
|
|
||||||
metaAtom[0] = byte(metaSize >> 24)
|
|
||||||
metaAtom[1] = byte(metaSize >> 16)
|
|
||||||
metaAtom[2] = byte(metaSize >> 8)
|
|
||||||
metaAtom[3] = byte(metaSize)
|
|
||||||
metaAtom = append(metaAtom, []byte("meta")...)
|
|
||||||
metaAtom = append(metaAtom, metaContent...)
|
|
||||||
|
|
||||||
return metaAtom
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
|
||||||
func buildTextAtom(name, value string) []byte {
|
|
||||||
valueBytes := []byte(value)
|
|
||||||
|
|
||||||
// data atom
|
|
||||||
dataSize := 16 + len(valueBytes)
|
|
||||||
dataAtom := make([]byte, 4)
|
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
|
||||||
dataAtom[1] = byte(dataSize >> 16)
|
|
||||||
dataAtom[2] = byte(dataSize >> 8)
|
|
||||||
dataAtom[3] = byte(dataSize)
|
|
||||||
dataAtom = append(dataAtom, []byte("data")...)
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
|
||||||
dataAtom = append(dataAtom, valueBytes...)
|
|
||||||
|
|
||||||
// container atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte(name)...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildTrackNumberAtom builds trkn atom
|
|
||||||
func buildTrackNumberAtom(track, total int) []byte {
|
|
||||||
// data atom with track number
|
|
||||||
dataAtom := []byte{
|
|
||||||
0, 0, 0, 24, // size
|
|
||||||
'd', 'a', 't', 'a',
|
|
||||||
0, 0, 0, 0, // type = implicit
|
|
||||||
0, 0, 0, 0, // locale
|
|
||||||
0, 0, // padding
|
|
||||||
byte(track >> 8), byte(track), // track number
|
|
||||||
byte(total >> 8), byte(total), // total tracks
|
|
||||||
0, 0, // padding
|
|
||||||
}
|
|
||||||
|
|
||||||
// trkn atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte("trkn")...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildDiscNumberAtom builds disk atom
|
|
||||||
func buildDiscNumberAtom(disc, total int) []byte {
|
|
||||||
// data atom with disc number
|
|
||||||
dataAtom := []byte{
|
|
||||||
0, 0, 0, 22, // size
|
|
||||||
'd', 'a', 't', 'a',
|
|
||||||
0, 0, 0, 0, // type = implicit
|
|
||||||
0, 0, 0, 0, // locale
|
|
||||||
0, 0, // padding
|
|
||||||
byte(disc >> 8), byte(disc), // disc number
|
|
||||||
byte(total >> 8), byte(total), // total discs
|
|
||||||
}
|
|
||||||
|
|
||||||
// disk atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte("disk")...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildCoverAtom builds covr atom with image data
|
|
||||||
func buildCoverAtom(coverData []byte) []byte {
|
|
||||||
// Detect image type (JPEG = 13, PNG = 14)
|
|
||||||
imageType := byte(13) // default JPEG
|
|
||||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
|
||||||
imageType = 14 // PNG
|
|
||||||
}
|
|
||||||
|
|
||||||
// data atom
|
|
||||||
dataSize := 16 + len(coverData)
|
|
||||||
dataAtom := make([]byte, 4)
|
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
|
||||||
dataAtom[1] = byte(dataSize >> 16)
|
|
||||||
dataAtom[2] = byte(dataSize >> 8)
|
|
||||||
dataAtom[3] = byte(dataSize)
|
|
||||||
dataAtom = append(dataAtom, []byte("data")...)
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
|
||||||
dataAtom = append(dataAtom, coverData...)
|
|
||||||
|
|
||||||
// covr atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte("covr")...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetM4AQuality reads audio quality from M4A file
|
|
||||||
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
data, err := os.ReadFile(filePath)
|
f, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
info, err := f.Stat()
|
||||||
moovPos := findAtom(data, "moov", 0)
|
if err != nil {
|
||||||
if moovPos < 0 {
|
return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err)
|
||||||
|
}
|
||||||
|
fileSize := info.Size()
|
||||||
|
|
||||||
|
moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err)
|
||||||
|
}
|
||||||
|
if !moovFound {
|
||||||
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for mp4a or alac atom which contains audio info
|
moovStart := moovHeader.offset
|
||||||
// This is a simplified search - real implementation would traverse the atom tree
|
moovEnd := moovHeader.offset + moovHeader.size
|
||||||
for i := moovPos; i < len(data)-20; i++ {
|
|
||||||
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
|
||||||
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
if err != nil {
|
||||||
if i+24 < len(data) {
|
return AudioQuality{}, err
|
||||||
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
|
||||||
// For AAC, bit depth is typically 16
|
|
||||||
bitDepth := 16
|
|
||||||
if string(data[i:i+4]) == "alac" {
|
|
||||||
// ALAC can have higher bit depth, check esds or alac specific data
|
|
||||||
bitDepth = 24 // Assume 24-bit for ALAC
|
|
||||||
}
|
|
||||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
|
buf := make([]byte, 24)
|
||||||
|
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleRate := int(buf[22])<<8 | int(buf[23])
|
||||||
|
bitDepth := 16
|
||||||
|
if atomType == "alac" {
|
||||||
|
bitDepth = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type atomHeader struct {
|
||||||
|
offset int64
|
||||||
|
size int64
|
||||||
|
headerSize int64
|
||||||
|
typ string
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) {
|
||||||
|
if offset+8 > fileSize {
|
||||||
|
return atomHeader{}, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
headerBuf := make([]byte, 8)
|
||||||
|
if _, err := f.ReadAt(headerBuf, offset); err != nil {
|
||||||
|
return atomHeader{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
size32 := binary.BigEndian.Uint32(headerBuf[0:4])
|
||||||
|
typ := string(headerBuf[4:8])
|
||||||
|
|
||||||
|
if size32 == 1 {
|
||||||
|
if offset+16 > fileSize {
|
||||||
|
return atomHeader{}, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
extBuf := make([]byte, 8)
|
||||||
|
if _, err := f.ReadAt(extBuf, offset+8); err != nil {
|
||||||
|
return atomHeader{}, err
|
||||||
|
}
|
||||||
|
size64 := binary.BigEndian.Uint64(extBuf)
|
||||||
|
return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) {
|
||||||
|
if size <= 0 {
|
||||||
|
return atomHeader{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
end := start + size
|
||||||
|
pos := start
|
||||||
|
|
||||||
|
for pos+8 <= end {
|
||||||
|
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||||
|
if err != nil {
|
||||||
|
return atomHeader{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
atomSize := header.size
|
||||||
|
if atomSize == 0 {
|
||||||
|
atomSize = end - pos
|
||||||
|
}
|
||||||
|
|
||||||
|
if atomSize < header.headerSize {
|
||||||
|
return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ)
|
||||||
|
}
|
||||||
|
|
||||||
|
header.size = atomSize
|
||||||
|
if header.typ == target {
|
||||||
|
return header, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += atomSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return atomHeader{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
||||||
|
const chunkSize = 64 * 1024
|
||||||
|
patternMP4A := []byte("mp4a")
|
||||||
|
patternALAC := []byte("alac")
|
||||||
|
|
||||||
|
var tail []byte
|
||||||
|
readPos := start
|
||||||
|
|
||||||
|
for readPos < end {
|
||||||
|
toRead := end - readPos
|
||||||
|
if toRead > chunkSize {
|
||||||
|
toRead = chunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, toRead)
|
||||||
|
n, err := f.ReadAt(buf, readPos)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
data := append(tail, buf[:n]...)
|
||||||
|
mp4aIdx := bytes.Index(data, patternMP4A)
|
||||||
|
alacIdx := bytes.Index(data, patternALAC)
|
||||||
|
|
||||||
|
bestIdx := -1
|
||||||
|
bestType := ""
|
||||||
|
switch {
|
||||||
|
case mp4aIdx >= 0 && alacIdx >= 0:
|
||||||
|
if mp4aIdx <= alacIdx {
|
||||||
|
bestIdx = mp4aIdx
|
||||||
|
bestType = "mp4a"
|
||||||
|
} else {
|
||||||
|
bestIdx = alacIdx
|
||||||
|
bestType = "alac"
|
||||||
|
}
|
||||||
|
case mp4aIdx >= 0:
|
||||||
|
bestIdx = mp4aIdx
|
||||||
|
bestType = "mp4a"
|
||||||
|
case alacIdx >= 0:
|
||||||
|
bestIdx = alacIdx
|
||||||
|
bestType = "alac"
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestIdx >= 0 {
|
||||||
|
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
||||||
|
if absolute+24 > fileSize {
|
||||||
|
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||||
|
}
|
||||||
|
return absolute, bestType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) >= 3 {
|
||||||
|
tail = append([]byte{}, data[len(data)-3:]...)
|
||||||
|
} else {
|
||||||
|
tail = append([]byte{}, data...)
|
||||||
|
}
|
||||||
|
|
||||||
|
readPos += int64(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// mobile_deps.go
|
||||||
|
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
||||||
|
// These packages are required by gomobile bind but not directly imported in code.
|
||||||
|
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
// Required for gomobile bind to work
|
||||||
|
_ "golang.org/x/mobile/bind"
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isFDOutput(outputFD int) bool {
|
||||||
|
return outputFD > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
||||||
|
if isFDOutput(outputFD) {
|
||||||
|
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
|
||||||
|
}
|
||||||
|
return os.Create(outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupOutputOnError(outputPath string, outputFD int) {
|
||||||
|
if isFDOutput(outputFD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimSpace(outputPath)
|
||||||
|
if path == "" || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
@@ -1,28 +1,25 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ISRC to Track ID Cache
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// TrackIDCacheEntry holds cached track ID with metadata
|
|
||||||
type TrackIDCacheEntry struct {
|
type TrackIDCacheEntry struct {
|
||||||
TidalTrackID int64
|
TidalTrackID int64
|
||||||
QobuzTrackID int64
|
QobuzTrackID int64
|
||||||
AmazonTrackID string
|
AmazonURL string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackIDCache caches ISRC to track ID mappings
|
|
||||||
type TrackIDCache struct {
|
type TrackIDCache struct {
|
||||||
cache map[string]*TrackIDCacheEntry
|
cache map[string]*TrackIDCacheEntry
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
|
lastCleanup time.Time
|
||||||
|
cleanupInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -30,30 +27,48 @@ var (
|
|||||||
trackIDCacheOnce sync.Once
|
trackIDCacheOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetTrackIDCache returns the global track ID cache
|
|
||||||
func GetTrackIDCache() *TrackIDCache {
|
func GetTrackIDCache() *TrackIDCache {
|
||||||
trackIDCacheOnce.Do(func() {
|
trackIDCacheOnce.Do(func() {
|
||||||
globalTrackIDCache = &TrackIDCache{
|
globalTrackIDCache = &TrackIDCache{
|
||||||
cache: make(map[string]*TrackIDCacheEntry),
|
cache: make(map[string]*TrackIDCacheEntry),
|
||||||
ttl: 30 * time.Minute, // Cache for 30 minutes
|
ttl: 30 * time.Minute,
|
||||||
|
cleanupInterval: 5 * time.Minute,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalTrackIDCache
|
return globalTrackIDCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a cached entry by ISRC
|
|
||||||
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
entry, exists := c.cache[isrc]
|
entry, exists := c.cache[isrc]
|
||||||
if !exists || time.Now().After(entry.ExpiresAt) {
|
if !exists {
|
||||||
|
c.mu.RUnlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return entry
|
expired := time.Now().After(entry.ExpiresAt)
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if !expired {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
entry, exists = c.cache[isrc]
|
||||||
|
if exists && time.Now().After(entry.ExpiresAt) {
|
||||||
|
delete(c.cache, isrc)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
|
||||||
|
for key, entry := range c.cache {
|
||||||
|
if now.After(entry.ExpiresAt) {
|
||||||
|
delete(c.cache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTidal caches Tidal track ID for an ISRC
|
|
||||||
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -64,10 +79,15 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
|||||||
c.cache[isrc] = entry
|
c.cache[isrc] = entry
|
||||||
}
|
}
|
||||||
entry.TidalTrackID = trackID
|
entry.TidalTrackID = trackID
|
||||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
now := time.Now()
|
||||||
|
entry.ExpiresAt = now.Add(c.ttl)
|
||||||
|
|
||||||
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||||
|
c.pruneExpiredLocked(now)
|
||||||
|
c.lastCleanup = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetQobuz caches Qobuz track ID for an ISRC
|
|
||||||
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -78,11 +98,16 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
|||||||
c.cache[isrc] = entry
|
c.cache[isrc] = entry
|
||||||
}
|
}
|
||||||
entry.QobuzTrackID = trackID
|
entry.QobuzTrackID = trackID
|
||||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
now := time.Now()
|
||||||
|
entry.ExpiresAt = now.Add(c.ttl)
|
||||||
|
|
||||||
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||||
|
c.pruneExpiredLocked(now)
|
||||||
|
c.lastCleanup = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAmazon caches Amazon track ID for an ISRC
|
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
|
||||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
@@ -91,29 +116,28 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
|||||||
entry = &TrackIDCacheEntry{}
|
entry = &TrackIDCacheEntry{}
|
||||||
c.cache[isrc] = entry
|
c.cache[isrc] = entry
|
||||||
}
|
}
|
||||||
entry.AmazonTrackID = trackID
|
entry.AmazonURL = amazonURL
|
||||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
now := time.Now()
|
||||||
|
entry.ExpiresAt = now.Add(c.ttl)
|
||||||
|
|
||||||
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||||
|
c.pruneExpiredLocked(now)
|
||||||
|
c.lastCleanup = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear removes all cached entries
|
|
||||||
func (c *TrackIDCache) Clear() {
|
func (c *TrackIDCache) Clear() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.cache = make(map[string]*TrackIDCacheEntry)
|
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size returns the number of cached entries
|
|
||||||
func (c *TrackIDCache) Size() int {
|
func (c *TrackIDCache) Size() int {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return len(c.cache)
|
return len(c.cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Parallel Download Helper
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// ParallelDownloadResult holds results from parallel operations
|
|
||||||
type ParallelDownloadResult struct {
|
type ParallelDownloadResult struct {
|
||||||
CoverData []byte
|
CoverData []byte
|
||||||
LyricsData *LyricsResponse
|
LyricsData *LyricsResponse
|
||||||
@@ -122,8 +146,6 @@ type ParallelDownloadResult struct {
|
|||||||
LyricsErr error
|
LyricsErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
|
||||||
// This runs while the main audio download is happening
|
|
||||||
func FetchCoverAndLyricsParallel(
|
func FetchCoverAndLyricsParallel(
|
||||||
coverURL string,
|
coverURL string,
|
||||||
maxQualityCover bool,
|
maxQualityCover bool,
|
||||||
@@ -131,47 +153,44 @@ func FetchCoverAndLyricsParallel(
|
|||||||
trackName string,
|
trackName string,
|
||||||
artistName string,
|
artistName string,
|
||||||
embedLyrics bool,
|
embedLyrics bool,
|
||||||
|
durationMs int64,
|
||||||
) *ParallelDownloadResult {
|
) *ParallelDownloadResult {
|
||||||
result := &ParallelDownloadResult{}
|
result := &ParallelDownloadResult{}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
// Download cover in parallel
|
|
||||||
if coverURL != "" {
|
if coverURL != "" {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Println("[Parallel] Starting cover download...")
|
|
||||||
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||||
|
resultMu.Lock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.CoverErr = err
|
result.CoverErr = err
|
||||||
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
|
|
||||||
} else {
|
} else {
|
||||||
result.CoverData = data
|
result.CoverData = data
|
||||||
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
|
|
||||||
}
|
}
|
||||||
|
resultMu.Unlock()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch lyrics in parallel
|
|
||||||
if embedLyrics {
|
if embedLyrics {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Println("[Parallel] Starting lyrics fetch...")
|
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
|
resultMu.Lock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.LyricsErr = err
|
result.LyricsErr = err
|
||||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
|
||||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
result.LyricsData = lyrics
|
result.LyricsData = lyrics
|
||||||
// Use LRC with metadata headers (like PC version)
|
|
||||||
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
|
||||||
} else {
|
} else {
|
||||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||||
fmt.Println("[Parallel] No lyrics found")
|
|
||||||
}
|
}
|
||||||
|
resultMu.Unlock()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,35 +198,28 @@ func FetchCoverAndLyricsParallel(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Pre-warm Cache for Album/Playlist
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// PreWarmCacheRequest represents a track to pre-warm cache for
|
|
||||||
type PreWarmCacheRequest struct {
|
type PreWarmCacheRequest struct {
|
||||||
ISRC string
|
ISRC string
|
||||||
TrackName string
|
TrackName string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
SpotifyID string // Needed for Amazon (SongLink lookup)
|
SpotifyID string
|
||||||
Service string // "tidal", "qobuz", "amazon"
|
Service string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
|
|
||||||
// This runs in background while user is viewing the track list
|
|
||||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||||
if len(requests) == 0 {
|
if len(requests) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
|
||||||
cache := GetTrackIDCache()
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
// Limit concurrent pre-warm requests
|
semaphore := make(chan struct{}, 3)
|
||||||
semaphore := make(chan struct{}, 3) // Max 3 concurrent
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, req := range requests {
|
for _, req := range requests {
|
||||||
// Skip if already cached
|
if req.ISRC == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if cached := cache.Get(req.ISRC); cached != nil {
|
if cached := cache.Get(req.ISRC); cached != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -215,14 +227,14 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(r PreWarmCacheRequest) {
|
go func(r PreWarmCacheRequest) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
semaphore <- struct{}{} // Acquire
|
semaphore <- struct{}{}
|
||||||
defer func() { <-semaphore }() // Release
|
defer func() { <-semaphore }()
|
||||||
|
|
||||||
switch r.Service {
|
switch r.Service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
preWarmQobuzCache(r.ISRC)
|
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||||
case "amazon":
|
case "amazon":
|
||||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||||
}
|
}
|
||||||
@@ -230,60 +242,84 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmTidalCache(isrc, trackName, artistName string) {
|
func preWarmTidalCache(isrc, _, _ string) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
GetTrackIDCache().SetTidal(isrc, track.ID)
|
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||||
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmQobuzCache(isrc string) {
|
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
|
||||||
|
// 1. From SongLink (fast, no Qobuz API call needed)
|
||||||
|
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||||
|
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||||
|
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
||||||
|
if spotifyID != "" {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
|
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||||
|
// Parse QobuzID to int64
|
||||||
|
var trackID int64
|
||||||
|
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||||
|
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||||
|
GetTrackIDCache().SetQobuz(isrc, trackID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Direct ISRC search on Qobuz API
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
|
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
|
||||||
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||||
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||||
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
|
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
if err == nil && availability != nil && availability.Amazon {
|
if err == nil && availability != nil && availability.AmazonURL != "" {
|
||||||
// Store Amazon URL in cache (using ISRC as key)
|
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
|
||||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
|
||||||
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Exported Functions for Flutter
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
|
|
||||||
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
|
||||||
func PreWarmCache(tracksJSON string) error {
|
func PreWarmCache(tracksJSON string) error {
|
||||||
var requests []PreWarmCacheRequest
|
var tracks []struct {
|
||||||
// Parse JSON (simplified - in production use proper JSON parsing)
|
ISRC string `json:"isrc"`
|
||||||
// For now, this is called from exports.go with proper parsing
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
go PreWarmTrackCache(requests) // Run in background
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||||
|
for i, t := range tracks {
|
||||||
|
requests[i] = PreWarmCacheRequest{
|
||||||
|
ISRC: t.ISRC,
|
||||||
|
TrackName: t.TrackName,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
SpotifyID: t.SpotifyID,
|
||||||
|
Service: t.Service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go PreWarmTrackCache(requests)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearTrackCache clears the track ID cache
|
|
||||||
func ClearTrackCache() {
|
func ClearTrackCache() {
|
||||||
GetTrackIDCache().Clear()
|
GetTrackIDCache().Clear()
|
||||||
fmt.Println("[Cache] Track ID cache cleared")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCacheSize returns the current cache size
|
|
||||||
func GetCacheSize() int {
|
func GetCacheSize() int {
|
||||||
return GetTrackIDCache().Size()
|
return GetTrackIDCache().Size()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress
|
|
||||||
// Now unified - returns data from multi-progress system
|
|
||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
CurrentFile string `json:"current_file"`
|
CurrentFile string `json:"current_file"`
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
@@ -15,21 +13,19 @@ type DownloadProgress struct {
|
|||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemProgress represents progress for a single download item
|
|
||||||
type ItemProgress struct {
|
type ItemProgress struct {
|
||||||
ItemID string `json:"item_id"`
|
ItemID string `json:"item_id"`
|
||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
Progress float64 `json:"progress"`
|
||||||
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
SpeedMBps float64 `json:"speed_mbps"`
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiProgress holds progress for multiple concurrent downloads
|
|
||||||
type MultiProgress struct {
|
type MultiProgress struct {
|
||||||
Items map[string]*ItemProgress `json:"items"`
|
Items map[string]*ItemProgress `json:"items"`
|
||||||
}
|
}
|
||||||
@@ -38,22 +34,18 @@ var (
|
|||||||
downloadDir string
|
downloadDir string
|
||||||
downloadDirMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
|
|
||||||
// Multi-download progress tracking (unified system)
|
|
||||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
multiMu sync.RWMutex
|
multiMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// getProgress returns current download progress from multi-progress system
|
|
||||||
// Returns first active item's progress for backward compatibility
|
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
|
|
||||||
// Find first active item
|
|
||||||
for _, item := range multiProgress.Items {
|
for _, item := range multiProgress.Items {
|
||||||
return DownloadProgress{
|
return DownloadProgress{
|
||||||
CurrentFile: item.ItemID,
|
CurrentFile: item.ItemID,
|
||||||
Progress: item.Progress * 100, // Convert to percentage
|
Progress: item.Progress * 100,
|
||||||
BytesTotal: item.BytesTotal,
|
BytesTotal: item.BytesTotal,
|
||||||
BytesReceived: item.BytesReceived,
|
BytesReceived: item.BytesReceived,
|
||||||
IsDownloading: item.IsDownloading,
|
IsDownloading: item.IsDownloading,
|
||||||
@@ -64,7 +56,6 @@ func getProgress() DownloadProgress {
|
|||||||
return DownloadProgress{}
|
return DownloadProgress{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMultiProgress returns progress for all active downloads as JSON
|
|
||||||
func GetMultiProgress() string {
|
func GetMultiProgress() string {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
@@ -76,7 +67,6 @@ func GetMultiProgress() string {
|
|||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetItemProgress returns progress for a specific item as JSON
|
|
||||||
func GetItemProgress(itemID string) string {
|
func GetItemProgress(itemID string) string {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
@@ -88,7 +78,6 @@ func GetItemProgress(itemID string) string {
|
|||||||
return "{}"
|
return "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartItemProgress initializes progress tracking for an item
|
|
||||||
func StartItemProgress(itemID string) {
|
func StartItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -103,7 +92,6 @@ func StartItemProgress(itemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemBytesTotal sets total bytes for an item
|
|
||||||
func SetItemBytesTotal(itemID string, total int64) {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -113,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemBytesReceived sets bytes received for an item
|
|
||||||
func SetItemBytesReceived(itemID string, received int64) {
|
func SetItemBytesReceived(itemID string, received int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -126,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
|
||||||
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -140,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteItemProgress marks an item as complete
|
|
||||||
func CompleteItemProgress(itemID string) {
|
func CompleteItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -152,7 +137,6 @@ func CompleteItemProgress(itemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemProgress sets progress for an item directly
|
|
||||||
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -168,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
|
||||||
func SetItemFinalizing(itemID string) {
|
func SetItemFinalizing(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -179,7 +162,6 @@ func SetItemFinalizing(itemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveItemProgress removes progress tracking for an item
|
|
||||||
func RemoveItemProgress(itemID string) {
|
func RemoveItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -187,7 +169,6 @@ func RemoveItemProgress(itemID string) {
|
|||||||
delete(multiProgress.Items, itemID)
|
delete(multiProgress.Items, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAllItemProgress clears all item progress
|
|
||||||
func ClearAllItemProgress() {
|
func ClearAllItemProgress() {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -195,7 +176,6 @@ func ClearAllItemProgress() {
|
|||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDownloadDir sets the default download directory
|
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
downloadDirMu.Lock()
|
downloadDirMu.Lock()
|
||||||
defer downloadDirMu.Unlock()
|
defer downloadDirMu.Unlock()
|
||||||
@@ -203,27 +183,18 @@ func setDownloadDir(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadDir returns the default download directory
|
|
||||||
func getDownloadDir() string {
|
|
||||||
downloadDirMu.RLock()
|
|
||||||
defer downloadDirMu.RUnlock()
|
|
||||||
return downloadDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
current int64
|
current int64
|
||||||
lastReported int64 // Track last reported bytes for threshold-based updates
|
lastReported int64
|
||||||
startTime time.Time // Track start time for speed calculation
|
startTime time.Time
|
||||||
lastTime time.Time // Track last update time for speed calculation
|
lastTime time.Time
|
||||||
lastBytes int64 // Track bytes at last speed calculation
|
lastBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
const progressUpdateThreshold = 64 * 1024
|
||||||
|
|
||||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &ItemProgressWriter{
|
return &ItemProgressWriter{
|
||||||
@@ -237,18 +208,17 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
|
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||||
|
return 0, ErrDownloadCancelled
|
||||||
|
}
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
|
|
||||||
// Update progress when we've received at least 64KB since last update
|
|
||||||
// Also update on first write to show download has started
|
|
||||||
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
// Calculate speed (MB/s) based on bytes received since last update
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
elapsed := now.Sub(pw.lastTime).Seconds()
|
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||||
var speedMBps float64
|
var speedMBps float64
|
||||||
@@ -256,7 +226,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
|||||||
bytesInInterval := pw.current - pw.lastBytes
|
bytesInInterval := pw.current - pw.lastBytes
|
||||||
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
|
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
|
||||||
}
|
}
|
||||||
|
|
||||||
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
|
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
|
||||||
pw.lastReported = pw.current
|
pw.lastReported = pw.current
|
||||||
pw.lastTime = now
|
pw.lastTime = now
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||||
|
t.Run("reads nested data.url", func(t *testing.T) {
|
||||||
|
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
||||||
|
|
||||||
|
got, err := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if got != "https://example.test/audio.flac" {
|
||||||
|
t.Fatalf("unexpected URL: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reads top-level url", func(t *testing.T) {
|
||||||
|
body := []byte(`{"url":"https://example.test/top.flac"}`)
|
||||||
|
|
||||||
|
got, err := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if got != "https://example.test/top.flac" {
|
||||||
|
t.Fatalf("unexpected URL: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns API error", func(t *testing.T) {
|
||||||
|
body := []byte(`{"error":"track not found"}`)
|
||||||
|
|
||||||
|
_, err := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if err == nil || err.Error() != "track not found" {
|
||||||
|
t.Fatalf("expected track-not-found error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns message when success false", func(t *testing.T) {
|
||||||
|
body := []byte(`{"success":false,"message":"blocked"}`)
|
||||||
|
|
||||||
|
_, err := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if err == nil || err.Error() != "blocked" {
|
||||||
|
t.Fatalf("expected blocked error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RateLimiter implements a sliding window rate limiter
|
|
||||||
type RateLimiter struct {
|
type RateLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
maxRequests int
|
maxRequests int
|
||||||
@@ -13,7 +12,6 @@ type RateLimiter struct {
|
|||||||
timestamps []time.Time
|
timestamps []time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRateLimiter creates a new rate limiter with specified max requests per window
|
|
||||||
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
||||||
return &RateLimiter{
|
return &RateLimiter{
|
||||||
maxRequests: maxRequests,
|
maxRequests: maxRequests,
|
||||||
@@ -22,39 +20,31 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForSlot blocks until a request is allowed under the rate limit
|
|
||||||
// Returns immediately if under the limit, otherwise waits until a slot is available
|
|
||||||
func (r *RateLimiter) WaitForSlot() {
|
func (r *RateLimiter) WaitForSlot() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Remove timestamps outside the window
|
|
||||||
r.cleanOldTimestamps(now)
|
r.cleanOldTimestamps(now)
|
||||||
|
|
||||||
// If under limit, record and return immediately
|
|
||||||
if len(r.timestamps) < r.maxRequests {
|
if len(r.timestamps) < r.maxRequests {
|
||||||
r.timestamps = append(r.timestamps, now)
|
r.timestamps = append(r.timestamps, now)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate wait time until oldest timestamp expires
|
|
||||||
oldestTimestamp := r.timestamps[0]
|
oldestTimestamp := r.timestamps[0]
|
||||||
waitUntil := oldestTimestamp.Add(r.window)
|
waitUntil := oldestTimestamp.Add(r.window)
|
||||||
waitDuration := waitUntil.Sub(now)
|
waitDuration := waitUntil.Sub(now)
|
||||||
|
|
||||||
if waitDuration > 0 {
|
if waitDuration > 0 {
|
||||||
// Release lock while waiting
|
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
time.Sleep(waitDuration)
|
time.Sleep(waitDuration)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|
||||||
// Clean again after waiting
|
|
||||||
r.cleanOldTimestamps(time.Now())
|
r.cleanOldTimestamps(time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record this request
|
|
||||||
r.timestamps = append(r.timestamps, time.Now())
|
r.timestamps = append(r.timestamps, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TryAcquire attempts to acquire a slot without blocking
|
|
||||||
// Returns true if successful, false if rate limit would be exceeded
|
|
||||||
func (r *RateLimiter) TryAcquire() bool {
|
func (r *RateLimiter) TryAcquire() bool {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
@@ -93,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available returns the number of requests available in the current window
|
|
||||||
func (r *RateLimiter) Available() int {
|
func (r *RateLimiter) Available() int {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
@@ -105,7 +92,6 @@ func (r *RateLimiter) Available() int {
|
|||||||
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
|
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
|
||||||
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
|
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
|
||||||
|
|
||||||
// GetSongLinkRateLimiter returns the global SongLink rate limiter
|
|
||||||
func GetSongLinkRateLimiter() *RateLimiter {
|
func GetSongLinkRateLimiter() *RateLimiter {
|
||||||
return songLinkRateLimiter
|
return songLinkRateLimiter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Hiragana to Romaji mapping
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
var hiraganaToRomaji = map[rune]string{
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
|
|||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Katakana to Romaji mapping
|
|
||||||
var katakanaToRomaji = map[rune]string{
|
var katakanaToRomaji = map[rune]string{
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
|
|||||||
'ヴ': "vu",
|
'ヴ': "vu",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combination mappings for きゃ, しゃ, etc.
|
|
||||||
var combinationHiragana = map[string]string{
|
var combinationHiragana = map[string]string{
|
||||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
|
|||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainsJapanese checks if a string contains Japanese characters
|
|
||||||
func ContainsJapanese(s string) bool {
|
func ContainsJapanese(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||||
@@ -114,8 +110,6 @@ func isKanji(r rune) bool {
|
|||||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||||
}
|
}
|
||||||
|
|
||||||
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
|
||||||
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
|
||||||
func JapaneseToRomaji(text string) string {
|
func JapaneseToRomaji(text string) string {
|
||||||
if !ContainsJapanese(text) {
|
if !ContainsJapanese(text) {
|
||||||
return text
|
return text
|
||||||
@@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string {
|
|||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSearchQuery creates a search query from track name and artist
|
|
||||||
// Converts Japanese to romaji if present
|
|
||||||
func BuildSearchQuery(trackName, artistName string) string {
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
// Convert Japanese to romaji
|
// Convert Japanese to romaji
|
||||||
trackRomaji := JapaneseToRomaji(trackName)
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
@@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string {
|
|||||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanSearchQuery removes special characters that might interfere with search
|
|
||||||
func cleanSearchQuery(s string) string {
|
func cleanSearchQuery(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string {
|
|||||||
return strings.TrimSpace(result.String())
|
return strings.TrimSpace(result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
|
||||||
// This is useful for creating search queries that work better with Tidal's search
|
|
||||||
func CleanToASCII(s string) string {
|
func CleanToASCII(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
|
|||||||
@@ -8,15 +8,12 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SongLinkClient handles song.link API interactions
|
|
||||||
type SongLinkClient struct {
|
type SongLinkClient struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackAvailability represents track availability on different platforms
|
|
||||||
type TrackAvailability struct {
|
type TrackAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
@@ -28,35 +25,27 @@ type TrackAvailability struct {
|
|||||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
DeezerURL string `json:"deezer_url,omitempty"`
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
DeezerID string `json:"deezer_id,omitempty"`
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
|
QobuzID string `json:"qobuz_id,omitempty"`
|
||||||
|
TidalID string `json:"tidal_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global SongLink client instance for connection reuse
|
|
||||||
globalSongLinkClient *SongLinkClient
|
globalSongLinkClient *SongLinkClient
|
||||||
songLinkClientOnce sync.Once
|
songLinkClientOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
songLinkClientOnce.Do(func() {
|
songLinkClientOnce.Do(func() {
|
||||||
globalSongLinkClient = &SongLinkClient{
|
globalSongLinkClient = &SongLinkClient{
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
client: NewMetadataHTTPClient(SongLinkTimeout),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalSongLinkClient
|
return globalSongLinkClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckTrackAvailability checks track availability on streaming platforms
|
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
// Validate Spotify ID format (should be 22 characters alphanumeric)
|
|
||||||
if spotifyTrackID == "" {
|
|
||||||
return nil, fmt.Errorf("spotify track ID is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use global rate limiter - blocks until request is allowed
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||||
|
|
||||||
@@ -68,7 +57,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use retry logic with User-Agent
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := DefaultRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,7 +64,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||||
}
|
}
|
||||||
@@ -109,35 +96,32 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
SpotifyID: spotifyTrackID,
|
SpotifyID: spotifyTrackID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
|
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
if isrc != "" {
|
availability.Qobuz = true
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.QobuzURL = qobuzLink.URL
|
||||||
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStreamingURLs gets streaming URLs for a Spotify track
|
|
||||||
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
||||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -155,48 +139,11 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
|
|||||||
return urls, nil
|
return urls, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkQobuzAvailability(isrc string) bool {
|
|
||||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
|
||||||
appID := "798273057"
|
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(client, req)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchResp struct {
|
|
||||||
Tracks struct {
|
|
||||||
Total int `json:"total"`
|
|
||||||
} `json:"tracks"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchResp.Tracks.Total > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||||
func extractDeezerIDFromURL(deezerURL string) string {
|
func extractDeezerIDFromURL(deezerURL string) string {
|
||||||
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
|
|
||||||
parts := strings.Split(deezerURL, "/")
|
parts := strings.Split(deezerURL, "/")
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
// Get the last part which should be the ID
|
|
||||||
lastPart := parts[len(parts)-1]
|
lastPart := parts[len(parts)-1]
|
||||||
// Remove any query parameters
|
|
||||||
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
||||||
lastPart = lastPart[:idx]
|
lastPart = lastPart[:idx]
|
||||||
}
|
}
|
||||||
@@ -205,17 +152,112 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
||||||
|
// URL formats:
|
||||||
|
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||||
|
// - https://open.qobuz.com/track/12345678
|
||||||
|
// - https://www.qobuz.com/track/12345678
|
||||||
|
// - https://play.qobuz.com/track/12345678
|
||||||
|
func extractQobuzIDFromURL(qobuzURL string) string {
|
||||||
|
if qobuzURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find /track/ID pattern first
|
||||||
|
if strings.Contains(qobuzURL, "/track/") {
|
||||||
|
parts := strings.Split(qobuzURL, "/track/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
idPart := parts[1]
|
||||||
|
// Remove query parameters
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
// Remove trailing slash or path
|
||||||
|
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
// Validate it's a number
|
||||||
|
if idPart != "" && isNumeric(idPart) {
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from album URL with track highlight
|
||||||
|
// Format: /album/albumname/trackid or ?trackId=12345678
|
||||||
|
if strings.Contains(qobuzURL, "trackId=") {
|
||||||
|
parts := strings.Split(qobuzURL, "trackId=")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
if idPart != "" && isNumeric(idPart) {
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: get last numeric segment from URL
|
||||||
|
parts := strings.Split(qobuzURL, "/")
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
part := parts[i]
|
||||||
|
// Remove query parameters
|
||||||
|
if idx := strings.Index(part, "?"); idx > 0 {
|
||||||
|
part = part[:idx]
|
||||||
|
}
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" && isNumeric(part) {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTidalIDFromURL extracts Tidal track ID from URL
|
||||||
|
// URL formats:
|
||||||
|
// - https://tidal.com/browse/track/12345678
|
||||||
|
// - https://listen.tidal.com/track/12345678
|
||||||
|
func extractTidalIDFromURL(tidalURL string) string {
|
||||||
|
if tidalURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(tidalURL, "/track/") {
|
||||||
|
parts := strings.Split(tidalURL, "/track/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
if idPart != "" && isNumeric(idPart) {
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNumeric is defined in library_scan.go
|
||||||
|
|
||||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Deezer || availability.DeezerID == "" {
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
return "", fmt.Errorf("track not found on Deezer")
|
return "", fmt.Errorf("track not found on Deezer")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,12 +269,9 @@ type AlbumAvailability struct {
|
|||||||
DeezerID string `json:"deezer_id,omitempty"`
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
|
||||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
// Use global rate limiter
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL for album
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||||
|
|
||||||
@@ -274,7 +313,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
|||||||
SpotifyID: spotifyAlbumID,
|
SpotifyID: spotifyAlbumID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
@@ -290,32 +328,40 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Deezer || availability.DeezerID == "" {
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
return "", fmt.Errorf("album not found on Deezer")
|
return "", fmt.Errorf("album not found on Deezer")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Deezer ID Support - Query SongLink using Deezer as source
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
|
||||||
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
||||||
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
if deezerTrackID == "" {
|
if deezerTrackID == "" {
|
||||||
return nil, fmt.Errorf("deezer track ID is empty")
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter
|
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
|
||||||
|
idhsClient := NewIDHSClient()
|
||||||
|
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
||||||
|
}
|
||||||
|
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
||||||
|
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build Deezer URL
|
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
// Build API URL using Deezer URL as source
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||||
|
|
||||||
@@ -331,7 +377,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||||
}
|
}
|
||||||
@@ -371,25 +416,27 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
DeezerID: deezerTrackID,
|
DeezerID: deezerTrackID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Spotify
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
// Extract Spotify ID from URL
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer URL
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
|
availability.Qobuz = true
|
||||||
|
availability.QobuzURL = qobuzLink.URL
|
||||||
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
}
|
}
|
||||||
@@ -397,7 +444,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
|
||||||
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||||
// entityType: "song" or "album"
|
// entityType: "song" or "album"
|
||||||
// entityID: the ID on that platform
|
// entityID: the ID on that platform
|
||||||
@@ -405,12 +451,9 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL using platform, type, and id parameters (as per API docs)
|
|
||||||
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
|
||||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||||
url.QueryEscape(platform),
|
url.QueryEscape(platform),
|
||||||
url.QueryEscape(entityType),
|
url.QueryEscape(entityType),
|
||||||
@@ -428,7 +471,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||||
}
|
}
|
||||||
@@ -459,24 +501,27 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
|
|
||||||
availability := &TrackAvailability{}
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
// Check Spotify
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
|
availability.Qobuz = true
|
||||||
|
availability.QobuzURL = qobuzLink.URL
|
||||||
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
@@ -488,10 +533,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
|
|
||||||
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
|
|
||||||
parts := strings.Split(spotifyURL, "/track/")
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
// Get the ID part and remove any query parameters
|
|
||||||
idPart := parts[1]
|
idPart := parts[1]
|
||||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
@@ -501,17 +544,16 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
|
||||||
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if availability.SpotifyID == "" {
|
if availability.SpotifyID == "" {
|
||||||
return "", fmt.Errorf("track not found on Spotify")
|
return "", fmt.Errorf("track not found on Spotify")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.SpotifyID, nil
|
return availability.SpotifyID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,24 +563,95 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Tidal || availability.TidalURL == "" {
|
if !availability.Tidal || availability.TidalURL == "" {
|
||||||
return "", fmt.Errorf("track not found on Tidal")
|
return "", fmt.Errorf("track not found on Tidal")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.TidalURL, nil
|
return availability.TidalURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
|
||||||
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Amazon || availability.AmazonURL == "" {
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
return "", fmt.Errorf("track not found on Amazon Music")
|
return "", fmt.Errorf("track not found on Amazon Music")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.AmazonURL, nil
|
return availability.AmazonURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
EntityID string `json:"entityUniqueId"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
|
}
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
|
availability.Qobuz = true
|
||||||
|
availability.QobuzURL = qobuzLink.URL
|
||||||
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
|
}
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,15 +16,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||||
|
|
||||||
// Cache TTL settings
|
|
||||||
artistCacheTTL = 10 * time.Minute
|
artistCacheTTL = 10 * time.Minute
|
||||||
searchCacheTTL = 5 * time.Minute
|
searchCacheTTL = 5 * time.Minute
|
||||||
albumCacheTTL = 10 * time.Minute
|
albumCacheTTL = 10 * time.Minute
|
||||||
@@ -33,7 +31,6 @@ const (
|
|||||||
|
|
||||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||||
|
|
||||||
// cacheEntry holds cached data with expiration
|
|
||||||
type cacheEntry struct {
|
type cacheEntry struct {
|
||||||
data interface{}
|
data interface{}
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
@@ -43,34 +40,31 @@ func (e *cacheEntry) isExpired() bool {
|
|||||||
return time.Now().After(e.expiresAt)
|
return time.Now().After(e.expiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpotifyMetadataClient handles Spotify API interactions
|
|
||||||
type SpotifyMetadataClient struct {
|
type SpotifyMetadataClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
clientID string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
cachedToken string
|
cachedToken string
|
||||||
tokenExpiresAt time.Time
|
tokenExpiresAt time.Time
|
||||||
tokenMu sync.Mutex // Protects token cache for concurrent access
|
tokenMu sync.Mutex
|
||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
rngMu sync.Mutex
|
rngMu sync.Mutex
|
||||||
userAgent string
|
userAgent string
|
||||||
|
|
||||||
// Caches to reduce API calls
|
artistCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry // key: artistID
|
searchCache map[string]*cacheEntry
|
||||||
searchCache map[string]*cacheEntry // key: query+type
|
albumCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry // key: albumID
|
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom credentials storage (set from Flutter)
|
|
||||||
var (
|
var (
|
||||||
customClientID string
|
customClientID string
|
||||||
customClientSecret string
|
customClientSecret string
|
||||||
credentialsMu sync.RWMutex
|
credentialsMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||||
// Pass empty strings to use default credentials
|
|
||||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
credentialsMu.Lock()
|
credentialsMu.Lock()
|
||||||
defer credentialsMu.Unlock()
|
defer credentialsMu.Unlock()
|
||||||
@@ -78,42 +72,49 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
|||||||
customClientSecret = clientSecret
|
customClientSecret = clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCredentials returns the current credentials (custom or default)
|
func HasSpotifyCredentials() bool {
|
||||||
func getCredentials() (string, string) {
|
|
||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return customClientID, customClientSecret
|
return true
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to default credentials
|
|
||||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
|
||||||
if clientID == "" {
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
|
||||||
clientID = string(decoded)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||||
if clientSecret == "" {
|
return true
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
|
||||||
clientSecret = string(decoded)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return clientID, clientSecret
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
func getCredentials() (string, string, error) {
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
credentialsMu.RLock()
|
||||||
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
if customClientID != "" && customClientSecret != "" {
|
||||||
|
return customClientID, customClientSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
|
||||||
|
if clientID != "" && clientSecret != "" {
|
||||||
|
return clientID, clientSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", ErrNoSpotifyCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||||
|
clientID, clientSecret, err := getCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
// Get credentials (custom or default)
|
|
||||||
clientID, clientSecret := getCredentials()
|
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
@@ -122,10 +123,9 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
|||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
}
|
}
|
||||||
c.userAgent = c.randomUserAgent()
|
c.userAgent = c.randomUserAgent()
|
||||||
return c
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackMetadata represents track information
|
|
||||||
type TrackMetadata struct {
|
type TrackMetadata struct {
|
||||||
SpotifyID string `json:"spotify_id,omitempty"`
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
@@ -140,9 +140,9 @@ type TrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumTrackMetadata holds per-track info for album/playlist
|
|
||||||
type AlbumTrackMetadata struct {
|
type AlbumTrackMetadata struct {
|
||||||
SpotifyID string `json:"spotify_id,omitempty"`
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
@@ -159,24 +159,26 @@ type AlbumTrackMetadata struct {
|
|||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
AlbumID string `json:"album_id,omitempty"`
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
AlbumURL string `json:"album_url,omitempty"`
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumInfoMetadata holds album information
|
|
||||||
type AlbumInfoMetadata struct {
|
type AlbumInfoMetadata struct {
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
|
ArtistId string `json:"artist_id,omitempty"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Copyright string `json:"copyright,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumResponsePayload is the response for album requests
|
|
||||||
type AlbumResponsePayload struct {
|
type AlbumResponsePayload struct {
|
||||||
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
||||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlaylistInfoMetadata holds playlist information
|
|
||||||
type PlaylistInfoMetadata struct {
|
type PlaylistInfoMetadata struct {
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
@@ -188,13 +190,11 @@ type PlaylistInfoMetadata struct {
|
|||||||
} `json:"owner"`
|
} `json:"owner"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlaylistResponsePayload is the response for playlist requests
|
|
||||||
type PlaylistResponsePayload struct {
|
type PlaylistResponsePayload struct {
|
||||||
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
||||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistInfoMetadata holds artist information
|
|
||||||
type ArtistInfoMetadata struct {
|
type ArtistInfoMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -203,35 +203,30 @@ type ArtistInfoMetadata struct {
|
|||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistAlbumMetadata holds album info for artist discography
|
|
||||||
type ArtistAlbumMetadata struct {
|
type ArtistAlbumMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
AlbumType string `json:"album_type"` // album, single, compilation
|
AlbumType string `json:"album_type"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistResponsePayload is the response for artist requests
|
|
||||||
type ArtistResponsePayload struct {
|
type ArtistResponsePayload struct {
|
||||||
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||||
Albums []ArtistAlbumMetadata `json:"albums"`
|
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackResponse is the response for single track requests
|
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
Track TrackMetadata `json:"track"`
|
Track TrackMetadata `json:"track"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResult represents search results
|
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
Tracks []TrackMetadata `json:"tracks"`
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchArtistResult represents an artist in search results
|
|
||||||
type SearchArtistResult struct {
|
type SearchArtistResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -240,10 +235,29 @@ type SearchArtistResult struct {
|
|||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAllResult represents combined search results for tracks and artists
|
type SearchAlbumResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchPlaylistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
type SearchAllResult struct {
|
type SearchAllResult struct {
|
||||||
Tracks []TrackMetadata `json:"tracks"`
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
Artists []SearchArtistResult `json:"artists"`
|
Artists []SearchArtistResult `json:"artists"`
|
||||||
|
Albums []SearchAlbumResult `json:"albums"`
|
||||||
|
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type spotifyURI struct {
|
type spotifyURI struct {
|
||||||
@@ -257,7 +271,6 @@ type accessTokenResponse struct {
|
|||||||
TokenType string `json:"token_type"`
|
TokenType string `json:"token_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal API response types
|
|
||||||
type image struct {
|
type image struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
@@ -283,6 +296,7 @@ type albumSimplified struct {
|
|||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type trackFull struct {
|
type trackFull struct {
|
||||||
@@ -297,7 +311,6 @@ type trackFull struct {
|
|||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFilteredData fetches and formats Spotify data
|
|
||||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||||
parsed, err := parseSpotifyURI(spotifyURL)
|
parsed, err := parseSpotifyURI(spotifyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -323,7 +336,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTracks searches for tracks on Spotify
|
|
||||||
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
|
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
|
||||||
token, err := c.getAccessToken(ctx)
|
token, err := c.getAccessToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -331,14 +343,14 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
|
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Items []trackFull `json:"items"`
|
Items []trackFull `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -363,18 +375,16 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
DiscNumber: track.DiscNumber,
|
DiscNumber: track.DiscNumber,
|
||||||
ExternalURL: track.ExternalURL.Spotify,
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
ISRC: track.ExternalID.ISRC,
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
AlbumType: track.Album.AlbumType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAll searches for tracks and artists on Spotify
|
|
||||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
// Create cache key
|
|
||||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -388,24 +398,24 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Items []trackFull `json:"items"`
|
Items []trackFull `json:"items"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
Artists struct {
|
Artists struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Followers struct {
|
Followers struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"followers"`
|
} `json:"followers"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
} `json:"artists"`
|
} `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -430,15 +440,15 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
DiscNumber: track.DiscNumber,
|
DiscNumber: track.DiscNumber,
|
||||||
ExternalURL: track.ExternalURL.Spotify,
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
ISRC: track.ExternalID.ISRC,
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
AlbumType: track.Album.AlbumType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit artists to artistLimit
|
|
||||||
artistCount := len(response.Artists.Items)
|
artistCount := len(response.Artists.Items)
|
||||||
if artistCount > artistLimit {
|
if artistCount > artistLimit {
|
||||||
artistCount = artistLimit
|
artistCount = artistLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < artistCount; i++ {
|
for i := 0; i < artistCount; i++ {
|
||||||
artist := response.Artists.Items[i]
|
artist := response.Artists.Items[i]
|
||||||
result.Artists = append(result.Artists, SearchArtistResult{
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
@@ -450,7 +460,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -487,7 +496,6 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -495,7 +503,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Track item structure for pagination
|
|
||||||
type trackItem struct {
|
type trackItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -523,19 +530,24 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
albumImage := firstImageURL(data.Images)
|
albumImage := firstImageURL(data.Images)
|
||||||
|
|
||||||
|
var firstArtistId string
|
||||||
|
if len(data.Artists) > 0 {
|
||||||
|
firstArtistId = data.Artists[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: data.TotalTracks,
|
TotalTracks: data.TotalTracks,
|
||||||
Name: data.Name,
|
Name: data.Name,
|
||||||
ReleaseDate: data.ReleaseDate,
|
ReleaseDate: data.ReleaseDate,
|
||||||
Artists: joinArtists(data.Artists),
|
Artists: joinArtists(data.Artists),
|
||||||
|
ArtistId: firstArtistId,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all tracks (including paginated)
|
|
||||||
allTrackItems := data.Tracks.Items
|
allTrackItems := data.Tracks.Items
|
||||||
nextURL := data.Tracks.Next
|
nextURL := data.Tracks.Next
|
||||||
|
|
||||||
// Fetch remaining tracks using pagination (no limit)
|
|
||||||
for nextURL != "" {
|
for nextURL != "" {
|
||||||
var pageData struct {
|
var pageData struct {
|
||||||
Items []trackItem `json:"items"`
|
Items []trackItem `json:"items"`
|
||||||
@@ -551,19 +563,17 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
|
|
||||||
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||||
|
|
||||||
// Collect track IDs for parallel ISRC fetching
|
|
||||||
trackIDs := make([]string, len(allTrackItems))
|
trackIDs := make([]string, len(allTrackItems))
|
||||||
for i, item := range allTrackItems {
|
for i, item := range allTrackItems {
|
||||||
trackIDs[i] = item.ID
|
trackIDs[i] = item.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||||
for _, item := range allTrackItems {
|
for _, item := range allTrackItems {
|
||||||
isrc := isrcMap[item.ID]
|
isrc := isrcMap[item.ID]
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.ID,
|
SpotifyID: item.ID,
|
||||||
Artists: joinArtists(item.Artists),
|
Artists: joinArtists(item.Artists),
|
||||||
@@ -587,7 +597,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.albumCache[albumID] = &cacheEntry{
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -598,49 +607,44 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
|
||||||
// Similar to Deezer implementation for consistency
|
|
||||||
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||||
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
const maxParallelISRC = 10
|
||||||
|
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
var resultMu sync.Mutex
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
if len(trackIDs) == 0 {
|
if len(trackIDs) == 0 {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use semaphore to limit concurrent requests
|
|
||||||
sem := make(chan struct{}, maxParallelISRC)
|
sem := make(chan struct{}, maxParallelISRC)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, trackID := range trackIDs {
|
for _, trackID := range trackIDs {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(id string) {
|
go func(id string) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
// Acquire semaphore
|
|
||||||
select {
|
select {
|
||||||
case sem <- struct{}{}:
|
case sem <- struct{}{}:
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isrc := c.fetchTrackISRC(ctx, id, token)
|
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||||
|
|
||||||
resultMu.Lock()
|
resultMu.Lock()
|
||||||
result[id] = isrc
|
result[id] = isrc
|
||||||
resultMu.Unlock()
|
resultMu.Unlock()
|
||||||
}(trackID)
|
}(trackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
// First request to get playlist info and first batch of tracks
|
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
@@ -666,10 +670,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
info.Owner.Name = data.Name
|
info.Owner.Name = data.Name
|
||||||
info.Owner.Images = firstImageURL(data.Images)
|
info.Owner.Images = firstImageURL(data.Images)
|
||||||
|
|
||||||
// Pre-allocate with expected capacity
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||||
|
|
||||||
// Add first batch of tracks
|
|
||||||
for _, item := range data.Tracks.Items {
|
for _, item := range data.Tracks.Items {
|
||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
@@ -693,9 +695,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
|
||||||
nextURL := data.Tracks.Next
|
nextURL := data.Tracks.Next
|
||||||
|
|
||||||
for nextURL != "" {
|
for nextURL != "" {
|
||||||
var pageData struct {
|
var pageData struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
@@ -705,7 +706,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
// Log error but return what we have so far
|
|
||||||
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -745,7 +745,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -753,12 +752,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Fetch artist info
|
|
||||||
var artistData struct {
|
var artistData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Followers struct {
|
Followers struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"followers"`
|
} `json:"followers"`
|
||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
@@ -776,7 +774,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
Popularity: artistData.Popularity,
|
Popularity: artistData.Popularity,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch artist albums (all types: album, single, compilation)
|
|
||||||
albums := make([]ArtistAlbumMetadata, 0)
|
albums := make([]ArtistAlbumMetadata, 0)
|
||||||
offset := 0
|
offset := 0
|
||||||
limit := 50
|
limit := 50
|
||||||
@@ -816,13 +813,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are more albums
|
|
||||||
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
offset += limit
|
offset += limit
|
||||||
|
|
||||||
// Safety limit to prevent infinite loops
|
|
||||||
if offset > 500 {
|
if offset > 500 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -833,7 +828,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
Albums: albums,
|
Albums: albums,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.artistCache[artistID] = &cacheEntry{
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -904,7 +898,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers (same as PC version baseHeaders)
|
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
@@ -940,16 +933,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
c.rngMu.Lock()
|
c.rngMu.Lock()
|
||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
// Use Mac User-Agent format (same as PC version)
|
macMajor := c.rng.Intn(4) + 11
|
||||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
macMinor := c.rng.Intn(5) + 4
|
||||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
webkitMajor := c.rng.Intn(7) + 530
|
||||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
webkitMinor := c.rng.Intn(7) + 30
|
||||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
chromeMajor := c.rng.Intn(25) + 80
|
||||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
chromeBuild := c.rng.Intn(1500) + 3000
|
||||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
chromePatch := c.rng.Intn(65) + 60
|
||||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
safariMajor := c.rng.Intn(7) + 530
|
||||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
safariMinor := c.rng.Intn(6) + 30
|
||||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||||
@@ -966,7 +958,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
return spotifyURI{}, errInvalidSpotifyURL
|
return spotifyURI{}, errInvalidSpotifyURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle spotify: URI format
|
|
||||||
if strings.HasPrefix(trimmed, "spotify:") {
|
if strings.HasPrefix(trimmed, "spotify:") {
|
||||||
parts := strings.Split(trimmed, ":")
|
parts := strings.Split(trimmed, ":")
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
@@ -977,13 +968,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle URL format
|
|
||||||
parsed, err := url.Parse(trimmed)
|
parsed, err := url.Parse(trimmed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spotifyURI{}, err
|
return spotifyURI{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle embed.spotify.com URLs
|
|
||||||
if parsed.Host == "embed.spotify.com" {
|
if parsed.Host == "embed.spotify.com" {
|
||||||
if parsed.RawQuery == "" {
|
if parsed.RawQuery == "" {
|
||||||
return spotifyURI{}, errInvalidSpotifyURL
|
return spotifyURI{}, errInvalidSpotifyURL
|
||||||
@@ -996,7 +985,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
return parseSpotifyURI(embedded)
|
return parseSpotifyURI(embedded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle plain ID (no scheme/host) - defaults to playlist
|
|
||||||
if parsed.Scheme == "" && parsed.Host == "" {
|
if parsed.Scheme == "" && parsed.Host == "" {
|
||||||
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
|
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@@ -1022,7 +1010,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
return spotifyURI{}, errInvalidSpotifyURL
|
return spotifyURI{}, errInvalidSpotifyURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip intl- prefix if present
|
|
||||||
if strings.HasPrefix(parts[0], "intl-") {
|
if strings.HasPrefix(parts[0], "intl-") {
|
||||||
parts = parts[1:]
|
parts = parts[1:]
|
||||||
}
|
}
|
||||||
@@ -1030,7 +1017,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
return spotifyURI{}, errInvalidSpotifyURL
|
return spotifyURI{}, errInvalidSpotifyURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
|
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
switch parts[0] {
|
switch parts[0] {
|
||||||
case "album", "track", "playlist", "artist":
|
case "album", "track", "playlist", "artist":
|
||||||
@@ -1038,7 +1024,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle nested playlist URLs: /user/{user}/playlist/{id}
|
|
||||||
if len(parts) == 4 && parts[2] == "playlist" {
|
if len(parts) == 4 && parts[2] == "playlist" {
|
||||||
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
|
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ import Gobackend // Import Go framework
|
|||||||
let itemId = args["item_id"] as! String
|
let itemId = args["item_id"] as! String
|
||||||
GobackendClearItemProgress(itemId)
|
GobackendClearItemProgress(itemId)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "cancelDownload":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendCancelDownload(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "setDownloadDirectory":
|
case "setDownloadDirectory":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -136,6 +142,27 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "checkDuplicatesBatch":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let outputDir = args["output_dir"] as! String
|
||||||
|
let tracksJson = args["tracks"] as? String ?? "[]"
|
||||||
|
let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "preBuildDuplicateIndex":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let outputDir = args["output_dir"] as! String
|
||||||
|
GobackendPreBuildDuplicateIndex(outputDir, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "invalidateDuplicateIndex":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let outputDir = args["output_dir"] as! String
|
||||||
|
GobackendInvalidateDuplicateIndex(outputDir)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "buildFilename":
|
case "buildFilename":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let template = args["template"] as! String
|
let template = args["template"] as! String
|
||||||
@@ -155,7 +182,8 @@ import Gobackend // Import Go framework
|
|||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error)
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -165,7 +193,8 @@ import Gobackend // Import Go framework
|
|||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let filePath = args["file_path"] as? String ?? ""
|
let filePath = args["file_path"] as? String ?? ""
|
||||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -193,7 +222,8 @@ import Gobackend // Import Go framework
|
|||||||
let query = args["query"] as! String
|
let query = args["query"] as! String
|
||||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||||
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error)
|
let filter = args["filter"] as? String ?? ""
|
||||||
|
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -212,6 +242,20 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "parseTidalUrl":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendParseTidalURLExport(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "convertTidalToSpotifyDeezer":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendConvertTidalToSpotifyDeezer(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "searchDeezerByISRC":
|
case "searchDeezerByISRC":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let isrc = args["isrc"] as! String
|
let isrc = args["isrc"] as! String
|
||||||
@@ -219,6 +263,13 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "getDeezerExtendedMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let trackId = args["track_id"] as! String
|
||||||
|
let response = GobackendGetDeezerExtendedMetadata(trackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "convertSpotifyToDeezer":
|
case "convertSpotifyToDeezer":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let resourceType = args["resource_type"] as! String
|
let resourceType = args["resource_type"] as! String
|
||||||
@@ -234,6 +285,43 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "checkAvailabilityFromDeezerID":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "checkAvailabilityByPlatformID":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let platform = args["platform"] as! String
|
||||||
|
let entityType = args["entity_type"] as! String
|
||||||
|
let entityId = args["entity_id"] as! String
|
||||||
|
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getSpotifyIDFromDeezerTrack":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getTidalURLFromDeezerTrack":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getAmazonURLFromDeezerTrack":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "preWarmTrackCache":
|
case "preWarmTrackCache":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let tracksJson = args["tracks"] as! String
|
let tracksJson = args["tracks"] as! String
|
||||||
@@ -256,6 +344,10 @@ import Gobackend // Import Go framework
|
|||||||
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "hasSpotifyCredentials":
|
||||||
|
let hasCredentials = GobackendCheckSpotifyCredentials()
|
||||||
|
return hasCredentials
|
||||||
|
|
||||||
// Log methods
|
// Log methods
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
let response = GobackendGetLogs()
|
let response = GobackendGetLogs()
|
||||||
@@ -281,6 +373,379 @@ import Gobackend // Import Go framework
|
|||||||
GobackendSetLoggingEnabled(enabled)
|
GobackendSetLoggingEnabled(enabled)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
// Extension System methods
|
||||||
|
case "initExtensionSystem":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionsDir = args["extensions_dir"] as! String
|
||||||
|
let dataDir = args["data_dir"] as! String
|
||||||
|
GobackendInitExtensionSystem(extensionsDir, dataDir, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "loadExtensionsFromDir":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let dirPath = args["dir_path"] as! String
|
||||||
|
let response = GobackendLoadExtensionsFromDir(dirPath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "loadExtensionFromPath":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendLoadExtensionFromPath(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "unloadExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
GobackendUnloadExtensionByID(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getInstalledExtensions":
|
||||||
|
let response = GobackendGetInstalledExtensions(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setExtensionEnabled":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let enabled = args["enabled"] as? Bool ?? false
|
||||||
|
GobackendSetExtensionEnabledByID(extensionId, enabled, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "setProviderPriority":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let priorityJson = args["priority"] as! String
|
||||||
|
GobackendSetProviderPriorityJSON(priorityJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getProviderPriority":
|
||||||
|
let response = GobackendGetProviderPriorityJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setMetadataProviderPriority":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let priorityJson = args["priority"] as! String
|
||||||
|
GobackendSetMetadataProviderPriorityJSON(priorityJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getMetadataProviderPriority":
|
||||||
|
let response = GobackendGetMetadataProviderPriorityJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getExtensionSettings":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendGetExtensionSettingsJSON(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setExtensionSettings":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let settingsJson = args["settings"] as! String
|
||||||
|
GobackendSetExtensionSettingsJSON(extensionId, settingsJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "invokeExtensionAction":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let actionName = args["action"] as! String
|
||||||
|
let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "searchTracksWithExtensions":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let limit = args["limit"] as? Int ?? 20
|
||||||
|
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "downloadWithExtensions":
|
||||||
|
let requestJson = call.arguments as! String
|
||||||
|
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "enrichTrackWithExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let trackJson = args["track"] as? String ?? "{}"
|
||||||
|
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "removeExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
GobackendRemoveExtensionByID(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "upgradeExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendUpgradeExtensionFromPath(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "checkExtensionUpgrade":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendCheckExtensionUpgradeFromPath(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "cleanupExtensions":
|
||||||
|
GobackendCleanupExtensions()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
// Extension Auth API
|
||||||
|
case "getExtensionPendingAuth":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendGetExtensionPendingAuthJSON(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setExtensionAuthCode":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let authCode = args["auth_code"] as! String
|
||||||
|
GobackendSetExtensionAuthCodeByID(extensionId, authCode)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "setExtensionTokens":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let accessToken = args["access_token"] as! String
|
||||||
|
let refreshToken = args["refresh_token"] as? String ?? ""
|
||||||
|
let expiresIn = args["expires_in"] as? Int ?? 0
|
||||||
|
GobackendSetExtensionTokensByID(extensionId, accessToken, refreshToken, Int(expiresIn))
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "clearExtensionPendingAuth":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
GobackendClearExtensionPendingAuthByID(extensionId)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "isExtensionAuthenticated":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendIsExtensionAuthenticatedByID(extensionId)
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getAllPendingAuthRequests":
|
||||||
|
let response = GobackendGetAllPendingAuthRequestsJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension FFmpeg API
|
||||||
|
case "getPendingFFmpegCommand":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let commandId = args["command_id"] as! String
|
||||||
|
let response = GobackendGetPendingFFmpegCommandJSON(commandId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setFFmpegCommandResult":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let commandId = args["command_id"] as! String
|
||||||
|
let success = args["success"] as? Bool ?? false
|
||||||
|
let output = args["output"] as? String ?? ""
|
||||||
|
let errorMsg = args["error"] as? String ?? ""
|
||||||
|
GobackendSetFFmpegCommandResult(commandId, success, output, errorMsg)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getAllPendingFFmpegCommands":
|
||||||
|
let response = GobackendGetAllPendingFFmpegCommandsJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension Custom Search API
|
||||||
|
case "customSearchWithExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let optionsJson = args["options"] as? String ?? ""
|
||||||
|
let response = GobackendCustomSearchWithExtensionJSON(extensionId, query, optionsJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getSearchProviders":
|
||||||
|
let response = GobackendGetSearchProvidersJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension URL Handler API
|
||||||
|
case "handleURLWithExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendHandleURLWithExtensionJSON(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "findURLHandler":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendFindURLHandlerJSON(url)
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getURLHandlers":
|
||||||
|
let response = GobackendGetURLHandlersJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getAlbumWithExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let albumId = args["album_id"] as! String
|
||||||
|
let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getPlaylistWithExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let playlistId = args["playlist_id"] as! String
|
||||||
|
let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getArtistWithExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let artistId = args["artist_id"] as! String
|
||||||
|
let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension Post-Processing API
|
||||||
|
case "runPostProcessing":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let metadataJson = args["metadata"] as? String ?? ""
|
||||||
|
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "runPostProcessingV2":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let inputJson = args["input"] as? String ?? ""
|
||||||
|
let metadataJson = args["metadata"] as? String ?? ""
|
||||||
|
let response = GobackendRunPostProcessingV2JSON(inputJson, metadataJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getPostProcessingProviders":
|
||||||
|
let response = GobackendGetPostProcessingProvidersJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Extension Store
|
||||||
|
case "initExtensionStore":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let cacheDir = args["cache_dir"] as! String
|
||||||
|
GobackendInitExtensionStoreJSON(cacheDir, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getStoreExtensions":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
||||||
|
let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "searchStoreExtensions":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as? String ?? ""
|
||||||
|
let category = args["category"] as? String ?? ""
|
||||||
|
let response = GobackendSearchStoreExtensionsJSON(query, category, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getStoreCategories":
|
||||||
|
let response = GobackendGetStoreCategoriesJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "downloadStoreExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let destDir = args["dest_dir"] as! String
|
||||||
|
let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearStoreCache":
|
||||||
|
GobackendClearStoreCacheJSON(&error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
// Extension Home Feed API
|
||||||
|
case "getExtensionHomeFeed":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getExtensionBrowseCategories":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Local Library Scanning
|
||||||
|
case "setLibraryCoverCacheDir":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let cacheDir = args["cache_dir"] as! String
|
||||||
|
GobackendSetLibraryCoverCacheDirJSON(cacheDir)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "scanLibraryFolder":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let folderPath = args["folder_path"] as! String
|
||||||
|
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "scanLibraryFolderIncremental":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let folderPath = args["folder_path"] as! String
|
||||||
|
let existingFiles = args["existing_files"] as? String ?? "{}"
|
||||||
|
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getLibraryScanProgress":
|
||||||
|
let response = GobackendGetLibraryScanProgressJSON()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "cancelLibraryScan":
|
||||||
|
GobackendCancelLibraryScanJSON()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "readAudioMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>en</string>
|
||||||
|
<string>de</string>
|
||||||
|
<string>es</string>
|
||||||
|
<string>fr</string>
|
||||||
|
<string>hi</string>
|
||||||
|
<string>id</string>
|
||||||
|
<string>ja</string>
|
||||||
|
<string>ko</string>
|
||||||
|
<string>nl</string>
|
||||||
|
<string>pt</string>
|
||||||
|
<string>ru</string>
|
||||||
|
<string>zh</string>
|
||||||
|
<string>zh-Hans</string>
|
||||||
|
<string>zh-Hant</string>
|
||||||
|
</array>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>SpotiFLAC</string>
|
<string>SpotiFLAC</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
@@ -50,7 +67,7 @@
|
|||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
||||||
<!-- File Sharing - Allow access via Files app -->
|
<!-- File Sharing - Allow access via Files app -->
|
||||||
@@ -64,5 +81,29 @@
|
|||||||
<!-- Photo Library (for cover art if needed) -->
|
<!-- Photo Library (for cover art if needed) -->
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>SpotiFLAC needs access to save album artwork</string>
|
<string>SpotiFLAC needs access to save album artwork</string>
|
||||||
|
|
||||||
|
<!-- URL Schemes for deep linking -->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Viewer</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.zarz.spotiflac</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>spotiflac</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Associated Domains for Universal Links -->
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>spotify</string>
|
||||||
|
<string>deezer</string>
|
||||||
|
<string>tidal</string>
|
||||||
|
<string>youtube-music</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
arb-dir: lib/l10n/arb
|
||||||
|
template-arb-file: app_en.arb
|
||||||
|
output-localization-file: app_localizations.dart
|
||||||
|
output-class: AppLocalizations
|
||||||
|
output-dir: lib/l10n
|
||||||
|
nullable-getter: false
|
||||||
@@ -1,17 +1,30 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotiflac_android/screens/main_shell.dart';
|
import 'package:spotiflac_android/screens/main_shell.dart';
|
||||||
import 'package:spotiflac_android/screens/setup_screen.dart';
|
import 'package:spotiflac_android/screens/setup_screen.dart';
|
||||||
|
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
|
||||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
|
||||||
|
|
||||||
|
// Determine initial location based on app state
|
||||||
|
String initialLocation;
|
||||||
|
if (isFirstLaunch) {
|
||||||
|
initialLocation = '/setup';
|
||||||
|
} else if (!hasCompletedTutorial) {
|
||||||
|
initialLocation = '/tutorial';
|
||||||
|
} else {
|
||||||
|
initialLocation = '/';
|
||||||
|
}
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: isFirstLaunch ? '/setup' : '/',
|
initialLocation: initialLocation,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -21,6 +34,10 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/setup',
|
path: '/setup',
|
||||||
builder: (context, state) => const SetupScreen(),
|
builder: (context, state) => const SetupScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/tutorial',
|
||||||
|
builder: (context, state) => const TutorialScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -31,6 +48,17 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final router = ref.watch(_routerProvider);
|
final router = ref.watch(_routerProvider);
|
||||||
|
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||||
|
|
||||||
|
Locale? locale;
|
||||||
|
if (localeString != 'system') {
|
||||||
|
if (localeString.contains('_')) {
|
||||||
|
final parts = localeString.split('_');
|
||||||
|
locale = Locale(parts[0], parts[1]);
|
||||||
|
} else {
|
||||||
|
locale = Locale(localeString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return DynamicColorWrapper(
|
return DynamicColorWrapper(
|
||||||
builder: (lightTheme, darkTheme, themeMode) {
|
builder: (lightTheme, darkTheme, themeMode) {
|
||||||
@@ -43,6 +71,14 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||||
themeAnimationCurve: Curves.easeInOut,
|
themeAnimationCurve: Curves.easeInOut,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.2.9';
|
static const String version = '3.5.2';
|
||||||
static const String buildNumber = '51';
|
static const String buildNumber = '76';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
@@ -17,4 +17,6 @@ class AppInfo {
|
|||||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
|
static const String bmacUrl = 'https://buymeacoffee.com/zarzet';
|
||||||
|
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||||
}
|
}
|
||||||
|
|||||||