Compare commits
441 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8615cde898 | |||
| 207c0653cc | |||
| de756e5d86 | |||
| fd5db3f7b6 | |||
| d087da9409 | |||
| 43469a7ef2 | |||
| add4af831e | |||
| 4e530ffbc3 | |||
| 14f6776fdc | |||
| da1c6e9171 | |||
| 9c3e934395 | |||
| 15d2c3b465 | |||
| 8aaa6d5cbe | |||
| 9158d0228d | |||
| 2bbcda3320 | |||
| a7622676dd | |||
| 5779f910a2 | |||
| 030f44a444 | |||
| 1248270fb4 | |||
| 413e3b0686 | |||
| ac711efadc | |||
| 59f2fe880a | |||
| 355f2eba2a | |||
| f2f45fa31d | |||
| 042937a8ed | |||
| 674e9af3d0 | |||
| 76d50fab3a | |||
| 81e25d7dab | |||
| 26f26f792a | |||
| 4dfa76b49e | |||
| f511f30ad0 | |||
| a1aa1319ce | |||
| c936bd7dd0 | |||
| 3a60ea2f4e | |||
| 7dba938299 | |||
| 93e77aeb84 | |||
| dd750b95ca | |||
| e42e44f28b | |||
| 67daefdf60 | |||
| fabaf0a3ff | |||
| fb90c73f42 | |||
| c6cf65f075 | |||
| 25de009ebc | |||
| 8918d74bb5 | |||
| f9de8d45d9 | |||
| 48eef0853d | |||
| fc70a912bf | |||
| cd3e5b4b28 | |||
| 482ca82eb4 | |||
| 6d87ae5484 | |||
| bd3e2b999b | |||
| 186196e12b | |||
| bd73eb292d | |||
| 8ee2919934 | |||
| f29177216d | |||
| 18d3612674 | |||
| f7c0e417d7 | |||
| 3fd13e9930 | |||
| 0b20cb895e | |||
| 8979210804 | |||
| e9b24712c5 | |||
| 3d6e5615fa | |||
| fc7220b572 | |||
| 198ed5ce6f | |||
| b48462a945 | |||
| 0f327cd1f6 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 | |||
| da9d64ccfd | |||
| 02e64b7a3c | |||
| a435009d4d | |||
| 9ca73a99a6 | |||
| 4974284760 | |||
| a0306bd345 | |||
| ea7e594c68 | |||
| d00a84f1b9 | |||
| 58b6203681 | |||
| d299144c47 | |||
| 40b224e5a1 | |||
| 7021e5493f | |||
| 68bbc8a259 | |||
| be94a59441 | |||
| 3a73aee1b7 | |||
| c91154ea3e | |||
| 4f365ca7fe | |||
| 98fdc0ed7c | |||
| 12be560cb8 | |||
| 4cf885a52e | |||
| c57c8a4267 | |||
| 2ca6c737c0 | |||
| 2a451ec2a3 | |||
| 346e79b247 | |||
| 497ba342c0 | |||
| aca0bbb819 | |||
| 2df8fd6282 | |||
| 999317eba1 | |||
| 16991476ed | |||
| ba33639818 | |||
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| b2873378fc | |||
| 66a89d9e8e | |||
| 814deca19d | |||
| 3bb6754d9c | |||
| 7d11d67cd2 | |||
| c0bd10cfca | |||
| e003b15ffd | |||
| ac1c7d31c9 | |||
| 6fc9ffeb23 | |||
| 9bebed506b | |||
| bffeb55a7a | |||
| c66d13c9fd | |||
| 8529985a0e | |||
| a8a3973225 | |||
| 6710f90e1e | |||
| 929c5f3249 | |||
| f170ead7b9 | |||
| e63e366228 | |||
| 95e755e54e | |||
| c719406425 | |||
| 9627ef66cf | |||
| 15f977d98d | |||
| 5b5f043624 | |||
| 529a920b24 | |||
| 09eb6cf206 | |||
| af6fa6ea53 | |||
| 280b921755 | |||
| 6ebe0c51ce | |||
| 47bd24c1bd | |||
| 2b23678c0d | |||
| e8327545ad | |||
| 89a38af538 | |||
| b7f34ec47c | |||
| 967523bfc6 | |||
| 29d8a185f9 | |||
| 4495d4bf4e | |||
| 67737467e0 | |||
| 13845eea04 | |||
| 12779778d3 | |||
| d4178ad036 | |||
| 49ea84384d | |||
| a6d9849468 | |||
| 16100aa0fd | |||
| 387dd47374 | |||
| 6ecb69feae | |||
| feff985439 | |||
| 2e8fe34824 | |||
| f58005f406 | |||
| 75abc03a4f | |||
| 84381d142a | |||
| f67f52eba9 | |||
| 3747ffff64 | |||
| ed47efed17 | |||
| c0d72e89d7 | |||
| a4313cfe0f | |||
| c7bef03ee3 | |||
| ce5a9e0cff | |||
| 859b823e77 | |||
| 7d8cf5f7ca | |||
| 4adaed8da0 | |||
| 554fe08fcd | |||
| b8af75bf6e | |||
| 35f2f119db | |||
| f36096e0ac | |||
| 1665e4cd57 | |||
| 42f0267277 | |||
| 82f59d32b9 | |||
| 941347b007 | |||
| 739c89569f | |||
| 18607597e9 | |||
| 7bb808cba5 | |||
| 78cd396847 | |||
| bb342c01e2 | |||
| 8a5dc0edfe | |||
| 8540da484f | |||
| 20f789f8e0 | |||
| 3e89326c95 | |||
| a7ea4de25a | |||
| aabfbf062e | |||
| 7b9ed3ec8e | |||
| 6dad66d62d | |||
| 31018230ee | |||
| 54ddc1f59c | |||
| c6856bd1a1 | |||
| 8c18c7b8f1 | |||
| 10c5293f64 | |||
| d5381afcf9 | |||
| 134bf4375f | |||
| aa9854fc0a | |||
| 10bc29e347 | |||
| 733efce161 | |||
| ac9141f167 | |||
| d89850e8a9 | |||
| 5948e4f125 | |||
| 34d22f783c | |||
| c347b6999e | |||
| adc74741ce | |||
| 48f614359e | |||
| 16669d8b7a | |||
| f1eef47600 | |||
| fc1567d2c8 | |||
| fffce6039a | |||
| cbfa147a12 | |||
| 5b8c953ae6 | |||
| 37a4dc096b | |||
| b3808645fb | |||
| 24aa804bf2 | |||
| 941ffb2bb7 | |||
| 59737d6f2b | |||
| c8ad93ee9b | |||
| 8cb0c037c2 | |||
| e30b69397b | |||
| d6e837fd61 | |||
| 5c97d202b9 | |||
| 0f6cfa75bb | |||
| 91bd6d1572 | |||
| df77ae3986 | |||
| 3cd6d068a2 | |||
| 29165da5ac | |||
| 9343583c69 | |||
| d82d255bae | |||
| 93a7042a84 | |||
| 5be5c869da | |||
| 8d45e023b2 | |||
| f2ae1398db | |||
| c2736a61fb | |||
| 76fe8dbc69 | |||
| dd05061829 | |||
| 8f6b99c550 | |||
| f54ee86591 | |||
| 42e0ec2663 | |||
| 0456a97b35 | |||
| 07c609cc3a | |||
| de5d26403f | |||
| 73c2d0efac | |||
| d3c1c440cc | |||
| 94195c636f | |||
| 9abf492362 | |||
| defc84c216 | |||
| 3c9ae39145 | |||
| 64408c8d8b | |||
| db55bb4693 | |||
| 9c6856b584 | |||
| 581f43f4c1 | |||
| 221d7e4829 | |||
| 706528f04b | |||
| f95a96dd1f | |||
| d85c16ce0f | |||
| 35afdf4be4 | |||
| eb5ed86019 | |||
| 0cfa6f56be | |||
| 5af88ead33 | |||
| 8ec63ee610 | |||
| c8247bf7a0 | |||
| 2f3270c7ff | |||
| 960d60f0bc | |||
| a4899144c5 | |||
| 808083c938 | |||
| 7e41ab4460 | |||
| 75a2bec8d5 | |||
| c35857bb61 | |||
| 2c897992c5 | |||
| 7d5cb574c6 | |||
| c582f96cf6 | |||
| 8fab3f60a7 | |||
| c6e981b3a1 | |||
| f0c5c5660a | |||
| 9c647bb31b | |||
| e1e82ac586 | |||
| 585d6da98d | |||
| bc279dd7fd | |||
| f2fdead6d3 | |||
| f66ccb4741 | |||
| 32c10c2b23 | |||
| 05674d9586 | |||
| 11bda9aae5 | |||
| 02c803385c | |||
| 8fe7a1e756 | |||
| 4a61ffea8d | |||
| 91548691ad | |||
| 36a646e5c0 | |||
| f306599ab2 | |||
| 3a7b777717 | |||
| 2334e659ad | |||
| 2a0216c87a | |||
| ab2d671760 | |||
| 5532d0a7d9 | |||
| 277e7719d3 | |||
| d6cb9fc261 | |||
| e6a857335f | |||
| e82e3a8343 | |||
| 6d812c76c2 | |||
| 5af4bb7ade | |||
| 030d66fd65 | |||
| c929f8d0a6 | |||
| 6fb50cfc67 | |||
| ebcdcf40dc | |||
| 76a05e717b | |||
| 062ce31cf7 | |||
| 98abaf6635 | |||
| 8675ab3215 | |||
| ad6ef2884a | |||
| 3ebb8a5e79 | |||
| 652b1b0821 | |||
| 4747119a7f | |||
| bfd769b349 | |||
| 40c3c73bfd | |||
| 96d11b1d7d | |||
| b3771f3488 | |||
| a07c125454 | |||
| 54a7b6b568 | |||
| 77d0ac4fce | |||
| bddd733466 | |||
| e6ffb08954 | |||
| 2fe8f659bc | |||
| ab26d84632 | |||
| a202ca4865 | |||
| a2db5bef25 | |||
| a81fa1ead7 | |||
| e7315cbc7e | |||
| cd757f177f | |||
| 103c55c072 | |||
| 765caab6df | |||
| 72f4663dd5 | |||
| deb6d92b55 | |||
| 0222ea6ccb | |||
| 8c047600a0 | |||
| 57b5877fdc | |||
| 7ddf67a977 | |||
| 7af2212d11 | |||
| 5e13651ed9 | |||
| 08e9c8d463 | |||
| b3d93880b5 | |||
| 05e100a492 | |||
| a4e22de455 | |||
| c89600591c | |||
| f1d57d89c7 | |||
| 83124875d3 | |||
| 9460e9faae | |||
| 882afd938b | |||
| ab72a10578 | |||
| d76d020cfe | |||
| e39756fa3f | |||
| 8e794e1ef1 | |||
| caf68c8137 | |||
| 5161ac8f77 | |||
| 4df96db809 | |||
| 5605930aef | |||
| 85bf3cfa84 | |||
| 8eec73d88c | |||
| b63dbbbfd5 | |||
| 8b16157047 | |||
| 6628682f97 | |||
| 5971ffc470 | |||
| baf95ec328 | |||
| 0a6590fafd | |||
| 22dd0ee0f6 | |||
| f9ad6046e8 | |||
| 8a21902fa1 | |||
| 016564eda7 | |||
| 5a8ff7db37 | |||
| cc08596adf | |||
| cdc5836785 | |||
| 813ed79073 | |||
| 537bab69ab | |||
| b0871ad94b | |||
| 0bd7574ab2 | |||
| c3f8b48bf7 | |||
| 90f731ac1e | |||
| e83fd66023 | |||
| d49bab403d | |||
| 8e6cbcbc2a | |||
| a6bef63aa7 | |||
| 898e28c40c | |||
| 9fda7ef596 | |||
| 17ba1713ad | |||
| f4110204b1 | |||
| d2a183b52d | |||
| a8dcf3113c | |||
| 1f52a6c9e0 | |||
| adbed63196 | |||
| 33e20845f1 | |||
| 9a7096c301 | |||
| 4c365032ff | |||
| bbd32d40a6 | |||
| 73f4a91fa1 | |||
| 1e2e383794 | |||
| 3b70b071e3 | |||
| 838c0ea421 | |||
| 8c722b0a18 | |||
| 3ece6770e1 | |||
| b39ec41255 | |||
| d4d661d6d4 | |||
| 2092f078ec | |||
| 924569aefb | |||
| a5864e15f8 | |||
| 564dd8bf95 | |||
| b317f7cd76 | |||
| a3b49d2642 | |||
| 6f20620c97 | |||
| b6a055a01a | |||
| 44ac593ddc | |||
| ca4c2a661e | |||
| 8b3b39f390 | |||
| 915934e5dd | |||
| 42f15018ae | |||
| 3554a7b5b9 | |||
| f2941939b7 | |||
| 1a77ded997 | |||
| 05d25d4d7c | |||
| 7cc1fef989 | |||
| 4a966e5e52 | |||
| d8ba4549aa | |||
| 309568becc | |||
| dd9b6dbfe3 | |||
| 4692b48174 | |||
| db82fa3ae1 | |||
| 5c42507b12 |
@@ -0,0 +1,20 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows scripts
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
|
||||||
|
# Binary files
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.jar binary
|
||||||
|
*.aar binary
|
||||||
|
*.keystore binary
|
||||||
|
*.jks binary
|
||||||
@@ -4,5 +4,5 @@ contact_links:
|
|||||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
about: Check the README for setup instructions and FAQ
|
about: Check the README for setup instructions and FAQ
|
||||||
- name: Extension Development Guide
|
- name: Extension Development Guide
|
||||||
url: https://zarz.moe/docs
|
url: https://spotiflac.zarz.moe/docs
|
||||||
about: Documentation for building SpotiFLAC extensions
|
about: Documentation for building SpotiFLAC extensions
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v5
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: site
|
path: site
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.25.8"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -93,12 +93,12 @@ jobs:
|
|||||||
# Accept licenses
|
# Accept licenses
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||||
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
# Set NDK path
|
# Set NDK path
|
||||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -164,17 +164,22 @@ jobs:
|
|||||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-15
|
||||||
needs: get-version # Only depends on version, NOT android build!
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Select Xcode 26.1.1
|
||||||
|
run: |
|
||||||
|
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
||||||
|
xcodebuild -version
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.25.8"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
@@ -309,32 +314,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Full history needed for git-cliff
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Generate changelog with git-cliff
|
||||||
id: changelog
|
id: changelog
|
||||||
|
uses: orhun/git-cliff-action@v4
|
||||||
|
with:
|
||||||
|
config: cliff.toml
|
||||||
|
args: --latest --strip header
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OUTPUT: /tmp/changelog.txt
|
||||||
|
|
||||||
|
- name: Show generated changelog
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
echo "Generated changelog:"
|
||||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
|
||||||
|
|
||||||
echo "Looking for version: $VERSION_NUM"
|
|
||||||
|
|
||||||
# Extract changelog section for this version using sed
|
|
||||||
# Find the line with version, then print until next version header or end
|
|
||||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
|
||||||
|
|
||||||
# If no changelog found, use default message
|
|
||||||
if [ -z "$CHANGELOG" ]; then
|
|
||||||
echo "No changelog found for version $VERSION_NUM"
|
|
||||||
CHANGELOG="See CHANGELOG.md for details."
|
|
||||||
else
|
|
||||||
echo "Found changelog content"
|
|
||||||
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
|
||||||
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Save to file for multiline support
|
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
|
||||||
echo "Extracted changelog:"
|
|
||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
@@ -352,15 +347,22 @@ jobs:
|
|||||||
- name: Prepare release body
|
- name: Prepare release body
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cat > /tmp/release_body.txt << 'HEADER'
|
|
||||||
### What's New
|
|
||||||
HEADER
|
|
||||||
|
|
||||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
|
||||||
|
|
||||||
REPO_OWNER="${{ github.repository_owner }}"
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
|
||||||
|
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Start with git-cliff changelog, but replace its compare footer with a
|
||||||
|
# deterministic previous-tag lookup from git.
|
||||||
|
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
|
||||||
|
|
||||||
|
if [ -n "$PREVIOUS_TAG" ]; then
|
||||||
|
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
|
||||||
|
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
|
||||||
|
>> /tmp/release_body.txt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Append download section
|
||||||
cat >> /tmp/release_body.txt << FOOTER
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -396,6 +398,63 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
update-altstore:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [get-version, build-ios, create-release]
|
||||||
|
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout main branch
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Download iOS IPA
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: ios-ipa
|
||||||
|
path: ./release
|
||||||
|
|
||||||
|
- name: Update apps.json
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.get-version.outputs.version }}"
|
||||||
|
VERSION_NUM="${VERSION#v}"
|
||||||
|
DATE=$(date -u +%Y-%m-%d)
|
||||||
|
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
||||||
|
|
||||||
|
if [ -z "$IPA_FILE" ]; then
|
||||||
|
echo "WARNING: IPA file not found, skipping apps.json update"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
||||||
|
|
||||||
|
if [ ! -f apps.json ]; then
|
||||||
|
echo "WARNING: apps.json not found on main, skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq --arg ver "$VERSION_NUM" \
|
||||||
|
--arg date "$DATE" \
|
||||||
|
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||||
|
--argjson size "$IPA_SIZE" \
|
||||||
|
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||||
|
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||||
|
|
||||||
|
echo "Updated apps.json:"
|
||||||
|
cat apps.json
|
||||||
|
|
||||||
|
- name: Commit and push
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.get-version.outputs.version }}"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add apps.json
|
||||||
|
git diff --cached --quiet && echo "No changes to commit" || \
|
||||||
|
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
||||||
|
|
||||||
notify-telegram:
|
notify-telegram:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [get-version, create-release]
|
needs: [get-version, create-release]
|
||||||
@@ -404,6 +463,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v7
|
||||||
@@ -417,52 +478,43 @@ jobs:
|
|||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Generate changelog with git-cliff for Telegram
|
||||||
|
uses: orhun/git-cliff-action@v4
|
||||||
|
with:
|
||||||
|
config: cliff.toml
|
||||||
|
args: --latest --strip all
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OUTPUT: /tmp/cliff_tg.txt
|
||||||
|
|
||||||
|
- name: Convert changelog for Telegram
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
if [ ! -s /tmp/cliff_tg.txt ]; then
|
||||||
VERSION_NUM=${VERSION#v}
|
echo "See release notes on GitHub for details." > /tmp/changelog.txt
|
||||||
|
|
||||||
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
|
|
||||||
# Use tr -d '\r' to handle CRLF line endings from Windows
|
|
||||||
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
|
|
||||||
|
|
||||||
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
|
|
||||||
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
|
|
||||||
|
|
||||||
if [ -z "$FULL_CHANGELOG" ]; then
|
|
||||||
CHANGELOG="See release notes on GitHub for details."
|
|
||||||
else
|
else
|
||||||
# Convert GitHub Markdown to Telegram HTML:
|
# Convert Markdown to Telegram HTML
|
||||||
# - **text** → <b>text</b>
|
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||||
# - `code` → <code>code</code>
|
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
||||||
# - ### Header → <b>Header</b>
|
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||||
# - Escape HTML special chars first
|
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
|
||||||
# - Remove > blockquote prefix
|
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
|
||||||
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||||
sed 's/^> //' | \
|
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||||
sed 's/&/\&/g' | \
|
sed 's/&/\&/g' | \
|
||||||
sed 's/</\</g' | \
|
sed 's/</\</g' | \
|
||||||
sed 's/>/\>/g' | \
|
sed 's/>/\>/g' | \
|
||||||
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
|
||||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
sed 's/^- /• /g' | \
|
sed 's/^- /• /g')
|
||||||
sed 's/^ - / ◦ /g')
|
|
||||||
|
# Truncate for Telegram 4096 char limit
|
||||||
# Take first 2500 characters, then cut at last complete line
|
|
||||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
# Check if truncated
|
|
||||||
FULL_LEN=${#FULL_CHANGELOG}
|
|
||||||
if [ $FULL_LEN -gt 2500 ]; then
|
|
||||||
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
echo "Telegram changelog:"
|
||||||
echo "DEBUG: Final changelog:"
|
|
||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Send to Telegram Channel
|
- name: Send to Telegram Channel
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ Thumbs.db
|
|||||||
# Kiro specs (development only)
|
# Kiro specs (development only)
|
||||||
.kiro/
|
.kiro/
|
||||||
|
|
||||||
|
# Design assets (banners, mockups)
|
||||||
|
design/
|
||||||
|
|
||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ AGENTS.md
|
|||||||
|
|
||||||
# Temp/misc
|
# Temp/misc
|
||||||
nul
|
nul
|
||||||
|
network_requests.txt
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
@@ -73,3 +77,7 @@ flutter_*.log
|
|||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# FVM Version Cache
|
||||||
|
.fvm/
|
||||||
|
|||||||
@@ -1,5 +1,211 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.7.2] - 2026-03-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Amazon Music is now an Extension**: Amazon Music has been moved from a built-in service to a separate installable extension. Install the "Amazon Music" extension from the Store to continue using it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz.
|
||||||
|
- **iOS Local Library Scan Fails**: Local library scanning was failing on iOS because the app lost access to user-picked folders after the FilePicker session ended. Implemented iOS security-scoped bookmark system:
|
||||||
|
- When a library folder is picked on iOS, a security-scoped bookmark is created and persisted in settings (`localLibraryBookmark`)
|
||||||
|
- Before each scan, the bookmark is resolved and security-scoped access is started; access is released in `finally` block after scan completes
|
||||||
|
- `cleanupMissingFiles` also activates the bookmark before checking file existence on iOS
|
||||||
|
- New `AppDelegate.swift` method channel handlers: `createIosBookmarkFromPath`, `startAccessingIosBookmark`, `stopAccessingIosBookmark`, `resolveIosBookmark`
|
||||||
|
- New `PlatformBridge` methods: `createIosBookmarkFromPath()`, `startAccessingIosBookmark()`, `stopAccessingIosBookmark()`
|
||||||
|
- All scan call-sites (Library Settings, Queue tab, Local Album screen) now pass the iOS bookmark to `startScan()`
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension.
|
||||||
|
- **Accessibility Tooltips**: Added localized tooltips to all `IconButton` and `PopupMenuButton` widgets across the entire UI for screen reader and long-press discoverability
|
||||||
|
- Back buttons use `MaterialLocalizations.backButtonTooltip`
|
||||||
|
- Close buttons use `MaterialLocalizations.closeButtonTooltip`
|
||||||
|
- Menu buttons use `MaterialLocalizations.showMenuTooltip`
|
||||||
|
- Search buttons use `MaterialLocalizations.searchFieldLabel`
|
||||||
|
- Contextual actions use descriptive labels: "Play track", "Dismiss", "Clear search", "Change folder", "Refresh"
|
||||||
|
- Screens affected: Album, Artist, Playlist, Downloaded Album, Local Album, Home, Search, Queue, Library Playlists, Library Tracks Folder, Setup, Tutorial, Track Metadata, Store, Extension Store Details, and all Settings sub-pages (About, Appearance, Cache Management, Donate, Download, Extensions, Extension Detail, Library, Log, Options, Provider Priority)
|
||||||
|
- **Semantics Wrappers**: Added `Semantics` widgets to interactive elements that previously had no accessibility information
|
||||||
|
- Album tiles in Artist screen: announces selection state and album name
|
||||||
|
- Recently downloaded track tiles in Home tab: announces track name and artist
|
||||||
|
- Explore items (albums/artists/playlists) in Home tab: announces item type and name
|
||||||
|
- Color palette picker in Appearance settings: announces selected state and color hex value
|
||||||
|
- Download button demo in Tutorial screen: added `ExcludeSemantics` on icon to prevent duplicate screen reader announcements
|
||||||
|
- Queue tab playlist cards: announces playlist name and item count
|
||||||
|
- Queue tab downloaded album cards: announces album name, artist, and track count
|
||||||
|
- Queue tab local album cards: announces album name, artist, and track count
|
||||||
|
- Queue tab play button on completed downloads: announces track name and artist with `ExcludeSemantics` on icon
|
||||||
|
- Queue tab download status indicators: "Finalizing download", "Download completed", "Downloaded file missing" labels with `ExcludeSemantics` on icons
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Code Formatting**: Reformatted and corrected indentation across multiple files to comply with Dart style guidelines
|
||||||
|
- `extension_detail_page.dart`: Fixed `SliverAppBar` and all subsequent slivers indentation (was 2 spaces short)
|
||||||
|
- `log_screen.dart`: Fixed `SliverAppBar` indentation alignment
|
||||||
|
- `donate_page.dart`: Reformatted ternary expressions and `_cr` function body
|
||||||
|
- `library_tracks_folder_screen.dart`: Minor line-break formatting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.7.1] - 2026-03-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Deezer Download Service**: Deezer is now available as a built-in download service (FLAC CD Quality).
|
||||||
|
- **Smarter YouTube Downloads**: If the YouTube Music extension is installed, the app now uses it first to find the correct song — more accurate than SongLink, especially for new releases.
|
||||||
|
- **Songs-Only Search Filter**: YouTube Music extension search now filters results server-side, so you only get actual songs — no music videos or covers mixed in.
|
||||||
|
- **Qobuz Squid.wtf Fallback**: Added Squid.wtf as an additional Qobuz download provider.
|
||||||
|
- **Qobuz Search Fallback**: If Qobuz API search returns nothing, the app now tries the Qobuz web store as a backup to find the track.
|
||||||
|
- **Better ISRC Lookup**: Tracks can now be resolved via ISRC even without a Spotify ID, using Deezer as an intermediary.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Download Queue Stability**: Fixed duplicate queue item IDs, cancel not working reliably, and "Clear All" not properly stopping active downloads.
|
||||||
|
- **Queue Restore on Restart**: Duplicate or broken queue item IDs are now auto-fixed when the app restarts.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Update Checker**: The app can now detect updates across all versions, not just within the same major version.
|
||||||
|
- **Localization Cleanup**: Cleaned up and consolidated translation files across all 13 supported languages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.7.0] - 2026-03-04
|
||||||
|
|
||||||
|
Hey everyone, thank you so much for sticking with SpotiFLAC Mobile.
|
||||||
|
|
||||||
|
Starting from this release, we're rolling the version back from **v4.x to v3.x**.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Internal Audio Player** — Removed `just_audio`, `audio_service`, and `audio_session` dependencies entirely. The internal playback engine (smart queue, media notification, shuffle/repeat, lyrics sync, prefetch, playback state persistence) has been completely removed. Playback now delegates to the system's external player.
|
||||||
|
- **PlaybackItem Model** — No longer needed without internal playback.
|
||||||
|
- **MiniPlayerBar Widget** — Removed the in-app mini player UI.
|
||||||
|
- **Media Notification Controls** — Removed notification drawables (`ic_stat_favorite`, `ic_stat_favorite_border`) and the `keep.xml` resource file.
|
||||||
|
- **Player Mode Setting** — The `playerMode` setting has been removed since external player is now the only mode.
|
||||||
|
- **Online Playback Feature** — Online streaming mode, DASH pipeline, and related components introduced in v4.0.0 are gone from the main branch.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **MainActivity** now extends `FlutterFragmentActivity` directly (previously `AudioServiceFragmentActivity`).
|
||||||
|
- **PlaybackController** simplified from ~1200 lines to ~87 lines — now only resolves local file paths and opens them via external player.
|
||||||
|
- **ProGuard rules** cleaned up — removed audio_service/just_audio/audio_session rules.
|
||||||
|
- **Qobuz** migrated to MusicDL API (Thanks @Ruubiiiii for Hosting the API).
|
||||||
|
|
||||||
|
### Note
|
||||||
|
There are three main reasons behind this decision:
|
||||||
|
|
||||||
|
1. **Respecting the API providers** — After giving it some thought, we realized that the streaming feature was indirectly hurting the API providers who have been generous enough to make their services available. They already offer streaming directly on their own websites, and it only feels right to direct streaming usage back to their platforms.
|
||||||
|
|
||||||
|
2. **Long-term sustainability** — We want SpotiFLAC to be around for as long as possible. Keeping certain features in the app could attract unwanted attention and put the project's continued existence at risk. Removing them is a proactive step to keep things running smoothly for everyone.
|
||||||
|
|
||||||
|
**Still want online playback? Check out these services:**
|
||||||
|
- [DabMusic](https://dabmusic.xyz)
|
||||||
|
- [SquidWTF](https://tidal.squid.wtf)
|
||||||
|
|
||||||
|
Thank you for your understanding and continued support. This decision was made to ensure the long-term sustainability of the app and to respect the ecosystem that has been supporting SpotiFLAC all along. You guys are the best, and we truly appreciate each and every one of you!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.6.0] - 2026-02-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Library Tab Redesign**: Wishlist, Loved, and individual Playlist collections now appear as unified list/grid items in the "All" tab alongside tracks, replacing the old "My Folders" horizontal card section
|
||||||
|
- **Drag-and-Drop Track Categorization**: Long-press-drag tracks onto playlist items to add them to that playlist; when multiple tracks are selected and one is dragged, all selected tracks are added to the target playlist
|
||||||
|
- Drag feedback widget displays multi-select count badge
|
||||||
|
- **Playlist Multi-Select Deletion**: Long-press playlists to enter selection mode, select multiple playlists, and batch-delete all selected at once via a dedicated selection bottom bar
|
||||||
|
- **Track Categorization System**: Tracks added to any playlist are automatically hidden from the main tracks list; removing a track from a playlist or deleting the playlist makes the track reappear — no actual file deletion ever occurs
|
||||||
|
- **Create Playlist Button**: New "+" `TextButton.icon` in Library tab header with dynamic theme colors, replacing the old "Select" button
|
||||||
|
- **Track Options Bottom Sheet**: Rewrote `TrackCollectionQuickActions` from inline action buttons to a single styled bottom sheet with track header (cover, title, artist), divider, and option tiles matching `DownloadServicePicker` visual style
|
||||||
|
- **Library Tracks Folder SliverAppBar**: Wishlist, Loved, and Playlist detail screens now feature a collapsible SliverAppBar with cover art (45% viewport height, parallax, gradient overlay), mode-specific icons (bookmark/heart/queue_music), title, and track count badge
|
||||||
|
- **Custom Playlist Cover Images**: Users can set custom cover images for playlists via long-press menu or camera icon in SliverAppBar
|
||||||
|
- Covers stored locally in app support directory with priority: custom cover > first track URL > icon fallback
|
||||||
|
- Cover options bottom sheet with change/remove actions
|
||||||
|
- Playlist list screen shows cover thumbnails
|
||||||
|
- **Long-Press Context Menus**: Track tiles in library folders and playlist list items now use long-press for styled bottom sheet context menus instead of trailing icon buttons, matching platform conventions
|
||||||
|
- **Wishlist Quick Download**: Tapping a track in Wishlist opens quality picker (respects "Ask quality before download" setting) and starts download
|
||||||
|
- **Playlist Track Playback**: Tapping a downloaded track in a Playlist opens it in the device's external music player via `openFile()` with file existence check
|
||||||
|
- **Collapsible AppBar on Playlist List Screen**: Playlist list screen now uses a collapsible SliverAppBar matching Settings sub-page style (animated title size 20→28px, animated left padding 56→24px) for visual consistency
|
||||||
|
- **`UnifiedLibraryItem.collectionKey` Getter**: Efficient playlist membership checking without constructing a full `Track` object
|
||||||
|
- **Multi-select Share**: Share multiple downloaded/local tracks at once from the selection bottom bar
|
||||||
|
- Supports SAF content URIs via native `ACTION_SEND_MULTIPLE` intent
|
||||||
|
- Supports regular file paths via SharePlus
|
||||||
|
- Available in Downloaded Album, Local Album, and Queue tab screens
|
||||||
|
- **Multi-select Batch Convert**: Convert multiple selected tracks to MP3 or Opus in one operation
|
||||||
|
- Bottom sheet UI with format (MP3 / Opus) and bitrate (128k / 192k / 256k / 320k) selection
|
||||||
|
- Full SAF support: copies to temp, converts, writes back, deletes original, updates history
|
||||||
|
- Progress and result snackbar feedback during conversion
|
||||||
|
- Available in Downloaded Album, Local Album, and Queue tab screens
|
||||||
|
- **Native `shareMultipleContentUris`**: New Android `ACTION_SEND_MULTIPLE` handler in `MainActivity` for sharing multiple SAF content URIs
|
||||||
|
- **Localization**: Added selection share/convert strings to all 13 supported locales (`selectionShareCount`, `selectionShareNoFiles`, `selectionConvertCount`, `selectionConvertNoConvertible`, `selectionBatchConvertConfirmTitle`, `selectionBatchConvertConfirmMessage`, `selectionBatchConvertProgress`, `selectionBatchConvertSuccess`)
|
||||||
|
- **Localization**: Added library collection l10n keys (`trackOptionAddToLoved`, `trackOptionRemoveFromLoved`, `trackOptionAddToWishlist`, `trackOptionRemoveFromWishlist`, `libraryTracksUnit`, `collectionPlaylistChangeCover`, `collectionPlaylistRemoveCover`)
|
||||||
|
- **Global Network Compatibility Mode**: New Download settings toggle to help restricted/ISP-filtered networks
|
||||||
|
- Applies to backend API requests (not SongLink-only)
|
||||||
|
- Enables HTTP scheme fallback and optional insecure TLS behavior in one switch
|
||||||
|
- Synced end-to-end across Flutter settings, platform channel (Android/iOS), and Go backend
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Removed "My Folders" Section**: Horizontal card section removed from Library tab header; collections are now inline items in the unified main list/grid
|
||||||
|
- **Playlist Subtitle Simplified**: Playlist items now show "N tracks" instead of "Playlist • N tracks"
|
||||||
|
- **Pinned App Bar on All Detail Screens**: `SliverAppBar` changed from `pinned: false` to `pinned: true` in 6 detail screens (album, downloaded album, local album, playlist, track metadata, library tracks folder) so the app bar stays visible when scrolling
|
||||||
|
- **Local Album Multi-select Action Updated**: Replaced batch `Share` action with batch `Re-enrich`
|
||||||
|
- Local album selection bar now uses `Re-enrich` + `Convert` actions
|
||||||
|
- Added batch re-enrich processing for local tracks (FLAC native path and MP3/Opus FFmpeg path, including SAF write-back flow)
|
||||||
|
- After batch re-enrich completes, local library is refreshed via incremental scan so updated metadata appears in UI immediately
|
||||||
|
- **Queue Multi-select Local Action Updated**: Queue selection bar now switches the first action to `Re-enrich` when selected items are local-only
|
||||||
|
- If selection contains downloaded or mixed items, action remains `Share`
|
||||||
|
- Local-only selection now supports batch re-enrich with the same native/FFmpeg + SAF flow and auto-refreshes local library metadata after completion
|
||||||
|
- **SongLink Network Option Scope Expanded**: The previous SongLink compatibility path now routes through global network compatibility controls so all supported backend API clients can benefit under problematic networks
|
||||||
|
- **Removed Per-Track Action Buttons**: Album, playlist, home, artist, and search screens no longer show individual download/add buttons on each track tile; all actions accessed via `TrackCollectionQuickActions` bottom sheet
|
||||||
|
- **Loved SliverAppBar Always Shows Heart Icon**: Loved tracks folder always displays the heart icon as cover, never uses first track's cover art (like Spotify's Liked Songs)
|
||||||
|
- **Wishlist Long-Press Menu Conditional Actions**: "Add to Playlist" option only appears when the track is already downloaded
|
||||||
|
- **Loved Track Tap Disabled**: Tapping a track in the Loved folder performs no action (long-press for options only)
|
||||||
|
- **Removed Duplicate Create Playlist Button**: Removed `+` IconButton from playlist list screen AppBar since the FAB already serves the same purpose
|
||||||
|
- **`coverImagePath` Field on `UserPlaylistCollection`**: Model now supports nullable custom cover path with `copyWith` using `String? Function()?` pattern for explicit null assignment
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Local Cover Path Handling**: All cover image renderers (Library tab, playlist detail screen hero cover, per-track tiles, options bottom sheet) now detect whether `coverUrl` is a URL or local file path and use `Image.file` for local paths instead of `CachedNetworkImage`
|
||||||
|
- **Empty Playlists Now Clickable**: Empty playlist items in Library tab can now be tapped to navigate to their detail screen
|
||||||
|
- **RenderFlex Overflow**: Fixed overflow in unified library item Row layout when track metadata text was too long
|
||||||
|
- **SAF FD Permission Denied on Tidal Downloads**: Fixed `failed to create file: open /proc/self/fd/*: permission denied` on some devices/providers
|
||||||
|
- Android SAF bridge now hands off detached raw FD (`output_fd`) to Go instead of forcing procfs path reopen
|
||||||
|
- Go output writer includes safer procfs fallback behavior for providers that reject truncate semantics
|
||||||
|
- **Batch Convert Lyrics Embedding Gap**: Batch convert in Downloaded Album, Local Album, and Queue now preserves/adds lyrics consistently like single convert
|
||||||
|
- Reuses embedded lyrics when available
|
||||||
|
- Falls back to sidecar `.lrc` when present
|
||||||
|
- Falls back to online lyrics fetch and injects into conversion metadata when embedding is enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.6.9] - 2026-02-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **YouTube Bitrate Presets**: YouTube bitrate selection now uses supported presets only
|
||||||
|
- Opus: 128 / 256 kbps
|
||||||
|
- MP3: 128 / 256 / 320 kbps
|
||||||
|
- **Go Test Coverage for YouTube Quality Parsing**: Added tests for supported-bitrate normalization behavior
|
||||||
|
- **Localization for YouTube Bitrate UI**: Added localized strings (EN/ID) for YouTube bitrate titles and labels
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Cover Image Cache Clear Not Working**: Clearing "Cover image cache" now performs a full on-disk wipe, clears in-memory image cache, and reinitializes cache manager state
|
||||||
|
- Prevents stale/orphaned cache files from keeping the same storage usage after clear
|
||||||
|
- **YouTube Queue Fallback Quality Mismatch**: Queue fallback now normalizes YouTube quality IDs so conversion paths use valid bitrate format IDs
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Default Lyrics Behavior**: `Apple/QQ Multi-Person Word-by-Word` is now OFF by default for new installs
|
||||||
|
- **Removed Dynamic YouTube Bitrate Mode**: Arbitrary values are now normalized to nearest supported Spotube preset across settings, picker, queue fallback, and Go backend parser
|
||||||
|
- **Lyrics Embedding Control**: Users can now disable the embedded-lyrics process from settings (`Embed Lyrics` off)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.6.8] - 2026-02-14
|
## [3.6.8] - 2026-02-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -128,7 +334,7 @@
|
|||||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||||
- New backend client for `spotify.afkarxyz.fun/api`
|
- New backend client for `sp.afkarxyz.qzz.io/api`
|
||||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||||
- Includes heuristic detection of lyrics stored in Comment fields
|
- Includes heuristic detection of lyrics stored in Comment fields
|
||||||
@@ -143,7 +349,7 @@
|
|||||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||||
|
|||||||
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
|
|||||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Install dependencies**
|
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||||
|
```bash
|
||||||
|
fvm use
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Install dependencies**
|
||||||
```bash
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||||
```bash
|
```bash
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Run the app**
|
6. **Set up Go environment (Go Version: 1.25.7)**
|
||||||
|
```bash
|
||||||
|
cd go_backend
|
||||||
|
mkdir -p ../android/app/libs
|
||||||
|
gomobile init
|
||||||
|
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||||
|
cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Run the app**
|
||||||
```bash
|
```bash
|
||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
|
||||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="icon.png" width="128" />
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||||
|
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||||
|
</picture>
|
||||||
|
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/17247">
|
||||||

|
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||||

|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -24,84 +34,154 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||||
|
|
||||||
### Installing Extensions
|
### Installing Extensions
|
||||||
1. Go to **Store** tab in the app
|
|
||||||
2. Browse and install extensions with one tap
|
1. Open the **Store** tab in the app
|
||||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||||
4. Configure extension settings if needed
|
3. Browse and install extensions with one tap
|
||||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||||
|
5. Configure extension settings if needed
|
||||||
|
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||||
|
|
||||||
### Developing Extensions
|
### Developing Extensions
|
||||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
|
||||||
|
|
||||||
## Other project
|
> [!NOTE]
|
||||||
|
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||||
|
|
||||||
## Telegram
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
|
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||||
|
|
||||||
[](https://t.me/spotiflac)
|
---
|
||||||
[](https://t.me/spotiflac_chat)
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
<details>
|
||||||
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
|
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Why are some tracks downloading in lower quality?**
|
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
||||||
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
|
||||||
|
|
||||||
**Q: Can I download playlists?**
|
</details>
|
||||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
|
||||||
|
|
||||||
**Q: Why do I need to grant storage permission?**
|
<details>
|
||||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Is this app safe?**
|
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
|
||||||
|
|
||||||
**Q: Why is download not working in my country?**
|
</details>
|
||||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
### Want to support SpotiFLAC-Mobile?
|
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||||
|
- **Tidal** up to 24-bit/192kHz
|
||||||
|
- **Qobuz** up to 24-bit/192kHz
|
||||||
|
- **Deezer** up to 16-bit/44.1kHz
|
||||||
|
|
||||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
</details>
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
<details>
|
||||||
|
<summary><b>Can I download playlists?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||||
|
|
||||||
## Disclaimer
|
</details>
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
<details>
|
||||||
|
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**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 app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
||||||
|
|
||||||
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
</details>
|
||||||
|
|
||||||
You are solely responsible for:
|
<details>
|
||||||
1. Ensuring your use of this software complies with your local laws.
|
<summary><b>Is this app safe?</b></summary>
|
||||||
2. Reading and adhering to the Terms of Service of the respective platforms.
|
<br>
|
||||||
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.
|
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||||
|
```
|
||||||
|
|
||||||
|
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If SpotiFLAC is useful to you, consider supporting development:
|
||||||
|
>
|
||||||
|
> [](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||||
|
|
||||||
|
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||||
|
|
||||||
|
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Credits
|
## 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)
|
| [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) |
|
||||||
- **Lyrics**: [LRCLib](https://lrclib.net), [Paxsenix](https://lyrics.paxsenix.org) (Apple Music/QQ Music lyrics proxy)
|
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
||||||
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
|
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
|
||||||
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This repository and its contents are provided strictly for educational and research purposes. The software is provided "as-is" without warranty of any kind, express or implied, as stated in the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
- No copyrighted content is hosted, stored, mirrored, or distributed by this repository.
|
||||||
|
- Users must ensure that their use of this software is properly authorized and complies with all applicable laws, regulations, and third-party terms of service.
|
||||||
|
- This software is provided free of charge by the maintainer. If you paid a third party for access to this software in its original form from this repository, you may have been misled or scammed. Any redistribution or commercial use by third parties must comply with the terms of the repository license. No affiliation, endorsement, or support by the maintainer is implied unless explicitly stated in writing.
|
||||||
|
- SpotiFLAC Mobile is an independent project. It is not affiliated with, endorsed by, or connected to any other project or version on other platforms that may share a similar name. The maintainer of this repository has no control over or responsibility for third-party projects.
|
||||||
|
- The author(s) disclaim all liability for any direct, indirect, incidental, or consequential damages arising from the use or misuse of this software. Users assume all risk associated with its use.
|
||||||
|
- If you are a copyright holder or authorized representative and believe this repository infringes upon your rights, please contact the maintainer with sufficient detail (including relevant URLs and proof of ownership). The matter will be promptly investigated and appropriate action will be taken, which may include removal of the referenced material.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
|
||||||
|
|||||||
@@ -9,6 +9,19 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- .dart_tool/**
|
||||||
|
- lib/**/*.g.dart
|
||||||
|
- lib/l10n/*.dart
|
||||||
|
language:
|
||||||
|
strict-casts: true
|
||||||
|
strict-inference: true
|
||||||
|
strict-raw-types: true
|
||||||
|
plugins:
|
||||||
|
- custom_lint
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -23,6 +36,13 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
avoid_dynamic_calls: true
|
||||||
|
cancel_subscriptions: true
|
||||||
|
close_sinks: true
|
||||||
|
|
||||||
|
custom_lint:
|
||||||
|
rules:
|
||||||
|
- avoid_public_notifier_properties
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getByName("profile") {
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
// For local builds: use release signing if key.properties exists
|
// For local builds: use release signing if key.properties exists
|
||||||
// For CI builds: APK is signed by GitHub Action after build
|
// For CI builds: APK is signed by GitHub Action after build
|
||||||
@@ -71,6 +83,9 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="false"
|
android:usesCleartextTraffic="false"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:localeConfig="@xml/locale_config">
|
android:localeConfig="@xml/locale_config">
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class DownloadService : Service() {
|
|||||||
updateNotification(progress, total)
|
updateNotification(progress, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return START_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -115,10 +115,8 @@ class DownloadService : Service() {
|
|||||||
* We must call stopSelf() within a few seconds to avoid a crash.
|
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||||
*/
|
*/
|
||||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
// Log the timeout for debugging
|
|
||||||
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
||||||
|
|
||||||
// Gracefully stop the service
|
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +137,13 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
// Acquire wake lock to prevent CPU sleep
|
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
wakeLock = powerManager.newWakeLock(
|
wakeLock = powerManager.newWakeLock(
|
||||||
PowerManager.PARTIAL_WAKE_LOCK,
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
WAKELOCK_TAG
|
WAKELOCK_TAG
|
||||||
).apply {
|
).apply {
|
||||||
acquire(60 * 60 * 1000L) // 1 hour max
|
acquire(60 * 60 * 1000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification = buildNotification(0, 0)
|
val notification = buildNotification(0, 0)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 651 B After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#1a1a2e</color>
|
<color name="ic_launcher_background">#000000</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false" />
|
||||||
|
|
||||||
|
<!-- Allow local loopback cleartext for FFmpeg live decrypt tunnel only. -->
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -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-9.3.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ 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.13.2" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "SpotiFLAC Source",
|
||||||
|
"identifier": "com.zarzet.spotiflac.source",
|
||||||
|
"subtitle": "FLAC Downloader for iOS",
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"name": "SpotiFLAC",
|
||||||
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
|
"developerName": "zarzet",
|
||||||
|
"version": "3.9.0",
|
||||||
|
"versionDate": "2026-03-25",
|
||||||
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
||||||
|
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||||
|
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
|
"size": 34477323
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,103 @@
|
|||||||
|
# git-cliff configuration for SpotiFLAC Mobile
|
||||||
|
# https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
# Template for the changelog body
|
||||||
|
body = """
|
||||||
|
{%- macro remote_url() -%}
|
||||||
|
https://github.com/zarzet/SpotiFLAC-Mobile
|
||||||
|
{%- endmacro -%}
|
||||||
|
|
||||||
|
{% if version %}\
|
||||||
|
## {{ version | trim_start_matches(pat="v") }}
|
||||||
|
{% else %}\
|
||||||
|
## Unreleased
|
||||||
|
{% endif %}\
|
||||||
|
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | striptags | trim | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
||||||
|
{{ commit.message | upper_first }}\
|
||||||
|
{% if commit.github.pr_number %} \
|
||||||
|
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
||||||
|
{% endif %}\
|
||||||
|
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
|
||||||
|
* @{{ contributor.username }} made their first contribution
|
||||||
|
{%- if contributor.pr_number %} in \
|
||||||
|
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
|
||||||
|
{%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{% if version %}
|
||||||
|
{% if previous.version %}
|
||||||
|
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
|
||||||
|
{% endif %}
|
||||||
|
{% else -%}
|
||||||
|
{% raw %}\n{% endraw %}
|
||||||
|
{% endif %}
|
||||||
|
"""
|
||||||
|
# Remove leading and trailing whitespace
|
||||||
|
trim = true
|
||||||
|
|
||||||
|
[git]
|
||||||
|
# Parse conventional commits
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
|
||||||
|
# Process each line of a commit as an individual commit
|
||||||
|
split_commits = false
|
||||||
|
|
||||||
|
# Regex for preprocessing the commit messages
|
||||||
|
commit_preprocessors = [
|
||||||
|
# Strip conventional commit prefix for cleaner messages
|
||||||
|
# (group header already shows the type)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Regex for parsing and grouping commits
|
||||||
|
commit_parsers = [
|
||||||
|
# Skip noise: translation commits from Crowdin
|
||||||
|
{ message = "^New translations", skip = true },
|
||||||
|
{ message = "^Update source file", skip = true },
|
||||||
|
# Skip merge commits
|
||||||
|
{ message = "^Merge", skip = true },
|
||||||
|
# Skip version bump commits
|
||||||
|
{ message = "^v\\d+", skip = true },
|
||||||
|
{ message = "^chore: update VirusTotal", skip = true },
|
||||||
|
|
||||||
|
# Group by conventional commit type
|
||||||
|
{ message = "^feat", group = "<!-- 0 -->New Features" },
|
||||||
|
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||||
|
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
|
||||||
|
{ message = "^doc", group = "<!-- 4 -->Documentation" },
|
||||||
|
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||||
|
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||||
|
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
|
||||||
|
{ message = "^chore\\(l10n\\)", skip = true },
|
||||||
|
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# Protect breaking changes from being skipped
|
||||||
|
protect_breaking_commits = true
|
||||||
|
|
||||||
|
# Filter out commits by matching patterns
|
||||||
|
filter_commits = false
|
||||||
|
|
||||||
|
# Tag pattern for version detection
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
|
|
||||||
|
# Sort commits by newest first
|
||||||
|
sort_commits = "newest"
|
||||||
|
|
||||||
|
[remote.github]
|
||||||
|
owner = "zarzet"
|
||||||
|
repo = "SpotiFLAC-Mobile"
|
||||||
@@ -1,662 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Amazon API timeout and retry configuration for mobile networks
|
|
||||||
const (
|
|
||||||
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
|
|
||||||
amazonMaxRetries = 2 // Number of retry attempts
|
|
||||||
amazonRetryDelay = 500 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
type AmazonDownloader struct {
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
globalAmazonDownloader *AmazonDownloader
|
|
||||||
amazonDownloaderOnce sync.Once
|
|
||||||
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
|
||||||
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
|
||||||
type AfkarXYZResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
DirectLink string `json:"direct_link"`
|
|
||||||
FileName string `json:"file_name"`
|
|
||||||
FileSize int64 `json:"file_size"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
|
|
||||||
type AmazonStreamResponse struct {
|
|
||||||
StreamURL string `json:"streamUrl"`
|
|
||||||
DecryptionKey string `json:"decryptionKey"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
|
||||||
amazonDownloaderOnce.Do(func() {
|
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return globalAmazonDownloader
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
|
||||||
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
|
||||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
|
||||||
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
|
|
||||||
time.Sleep(delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
|
||||||
if err == nil {
|
|
||||||
return downloadURL, fileName, decryptionKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lastErr = err
|
|
||||||
errStr := strings.ToLower(err.Error())
|
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
|
||||||
strings.Contains(errStr, "connection reset") ||
|
|
||||||
strings.Contains(errStr, "connection refused") ||
|
|
||||||
strings.Contains(errStr, "eof") ||
|
|
||||||
strings.Contains(errStr, "status 5") ||
|
|
||||||
strings.Contains(errStr, "status 429") ||
|
|
||||||
strings.Contains(errStr, "http 429")
|
|
||||||
|
|
||||||
if !isRetryable {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeAmazonASIN(candidate string) string {
|
|
||||||
trimmed := strings.TrimSpace(candidate)
|
|
||||||
if trimmed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
|
||||||
trimmed = decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
trimmed = strings.ToUpper(trimmed)
|
|
||||||
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
|
||||||
trimmed = trimmed[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
if amazonASINRegex.MatchString(trimmed) {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractAmazonASIN(amazonURL string) string {
|
|
||||||
raw := strings.TrimSpace(amazonURL)
|
|
||||||
if raw == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(raw)
|
|
||||||
if err == nil {
|
|
||||||
query := parsed.Query()
|
|
||||||
|
|
||||||
// Prefer track-level ASIN when URL also contains albumAsin.
|
|
||||||
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
|
||||||
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
|
||||||
return asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
path := strings.Trim(parsed.Path, "/")
|
|
||||||
if path != "" {
|
|
||||||
segments := strings.Split(path, "/")
|
|
||||||
|
|
||||||
for i := 0; i < len(segments)-1; i++ {
|
|
||||||
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
|
||||||
if segment == "track" || segment == "tracks" {
|
|
||||||
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
|
||||||
return asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
|
||||||
return asin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
|
||||||
return normalizeAmazonASIN(match)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doAfkarXYZRequest performs a single request to Amazon API.
|
|
||||||
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
|
||||||
asin := extractAmazonASIN(amazonURL)
|
|
||||||
if asin != "" {
|
|
||||||
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
|
||||||
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
|
||||||
if err == nil {
|
|
||||||
return downloadURL, fileName, decryptKey, nil
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
|
||||||
}
|
|
||||||
return a.doAfkarXYZRequestLegacy(amazonURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp AmazonStreamResponse
|
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(apiResp.StreamURL) == "" {
|
|
||||||
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := asin + ".m4a"
|
|
||||||
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiResp AfkarXYZResponse
|
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
|
||||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := apiResp.Data.FileName
|
|
||||||
if fileName == "" {
|
|
||||||
fileName = "track.flac"
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
|
||||||
fileName = reg.ReplaceAllString(fileName, "")
|
|
||||||
|
|
||||||
return apiResp.Data.DirectLink, fileName, "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
|
||||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
|
||||||
|
|
||||||
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if decryptionKey != "" {
|
|
||||||
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
|
||||||
return downloadURL, fileName, decryptionKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if itemID != "" {
|
|
||||||
StartItemProgress(itemID)
|
|
||||||
defer CompleteItemProgress(itemID)
|
|
||||||
ctx = initDownloadCancel(itemID)
|
|
||||||
defer clearDownloadCancel(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := openOutputForWrite(outputPath, outputFD)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
||||||
|
|
||||||
var written int64
|
|
||||||
if itemID != "" {
|
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
|
||||||
written, err = io.Copy(pw, resp.Body)
|
|
||||||
} else {
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
flushErr := bufWriter.Flush()
|
|
||||||
closeErr := out.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
|
||||||
}
|
|
||||||
if flushErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
|
||||||
type AmazonDownloadResult struct {
|
|
||||||
FilePath string
|
|
||||||
BitDepth int
|
|
||||||
SampleRate int
|
|
||||||
Title string
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
ReleaseDate string
|
|
||||||
TrackNumber int
|
|
||||||
DiscNumber int
|
|
||||||
ISRC string
|
|
||||||
LyricsLRC string
|
|
||||||
DecryptionKey string
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|
||||||
downloader := NewAmazonDownloader()
|
|
||||||
|
|
||||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
||||||
if !isSafOutput {
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
var availability *TrackAvailability
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if amazonURL == "" {
|
|
||||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
|
||||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
|
||||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
|
||||||
} else if req.SpotifyID != "" {
|
|
||||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
|
||||||
} else {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !availability.Amazon || availability.AmazonURL == "" {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonURL = availability.AmazonURL
|
|
||||||
if req.ISRC != "" {
|
|
||||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isSafOutput && req.OutputDir != "." {
|
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download using AfkarXYZ API
|
|
||||||
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
|
||||||
if err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
|
||||||
"title": req.TrackName,
|
|
||||||
"artist": req.ArtistName,
|
|
||||||
"album": req.AlbumName,
|
|
||||||
"track": req.TrackNumber,
|
|
||||||
"year": extractYear(req.ReleaseDate),
|
|
||||||
"date": req.ReleaseDate,
|
|
||||||
"disc": req.DiscNumber,
|
|
||||||
})
|
|
||||||
var outputPath string
|
|
||||||
if isSafOutput {
|
|
||||||
outputPath = strings.TrimSpace(req.OutputPath)
|
|
||||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
|
||||||
if outputExt == "" {
|
|
||||||
outputExt = ".flac"
|
|
||||||
}
|
|
||||||
filename = sanitizeFilename(filename) + outputExt
|
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
|
||||||
var parallelResult *ParallelDownloadResult
|
|
||||||
parallelDone := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(parallelDone)
|
|
||||||
parallelResult = FetchCoverAndLyricsParallel(
|
|
||||||
req.CoverURL,
|
|
||||||
req.EmbedMaxQualityCover,
|
|
||||||
req.SpotifyID,
|
|
||||||
req.TrackName,
|
|
||||||
req.ArtistName,
|
|
||||||
req.EmbedLyrics,
|
|
||||||
int64(req.DurationMS),
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
|
||||||
if errors.Is(err, ErrDownloadCancelled) {
|
|
||||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
actualOutputPath := outputPath
|
|
||||||
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
|
||||||
if needsDecryption {
|
|
||||||
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
|
||||||
<-parallelDone
|
|
||||||
|
|
||||||
if req.ItemID != "" {
|
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
|
||||||
SetItemFinalizing(req.ItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
actualTrackNum := req.TrackNumber
|
|
||||||
actualDiscNum := req.DiscNumber
|
|
||||||
actualDate := req.ReleaseDate
|
|
||||||
actualAlbum := req.AlbumName
|
|
||||||
actualTitle := req.TrackName
|
|
||||||
actualArtist := req.ArtistName
|
|
||||||
|
|
||||||
if !needsDecryption {
|
|
||||||
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
|
||||||
}
|
|
||||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
|
||||||
actualDiscNum = existingMeta.DiscNumber
|
|
||||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
|
||||||
}
|
|
||||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
|
||||||
actualDate = existingMeta.Date
|
|
||||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
|
||||||
}
|
|
||||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
|
||||||
actualAlbum = existingMeta.Album
|
|
||||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
|
||||||
}
|
|
||||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
|
||||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: actualTitle,
|
|
||||||
Artist: actualArtist,
|
|
||||||
Album: actualAlbum,
|
|
||||||
AlbumArtist: req.AlbumArtist,
|
|
||||||
Date: actualDate,
|
|
||||||
TrackNumber: actualTrackNum,
|
|
||||||
TotalTracks: req.TotalTracks,
|
|
||||||
DiscNumber: actualDiscNum,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
Genre: req.Genre,
|
|
||||||
Label: req.Label,
|
|
||||||
Copyright: req.Copyright,
|
|
||||||
}
|
|
||||||
|
|
||||||
var coverData []byte
|
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
|
|
||||||
coverData = parallelResult.CoverData
|
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
|
||||||
if coverErr == nil && len(existingCover) > 0 {
|
|
||||||
coverData = existingCover
|
|
||||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSafOutput || needsDecryption {
|
|
||||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
|
||||||
} else {
|
|
||||||
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
|
||||||
if isFlacOutput {
|
|
||||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsMode := req.LyricsMode
|
|
||||||
if lyricsMode == "" {
|
|
||||||
lyricsMode = "embed"
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
|
||||||
GoLog("[Amazon] Saving external LRC file...\n")
|
|
||||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
|
||||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
|
||||||
}
|
|
||||||
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
|
||||||
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
|
||||||
}
|
|
||||||
} else if req.EmbedLyrics {
|
|
||||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
|
||||||
|
|
||||||
quality := AudioQuality{}
|
|
||||||
if isSafOutput || needsDecryption {
|
|
||||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
|
||||||
} else {
|
|
||||||
quality, err = GetAudioQuality(actualOutputPath)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
|
||||||
}
|
|
||||||
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
|
||||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
|
||||||
actualTrackNum = finalMeta.TrackNumber
|
|
||||||
actualDiscNum = finalMeta.DiscNumber
|
|
||||||
if finalMeta.Date != "" {
|
|
||||||
req.ReleaseDate = finalMeta.Date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking.
|
|
||||||
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
|
||||||
if !isSafOutput && !needsDecryption {
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
bitDepth := 0
|
|
||||||
sampleRate := 0
|
|
||||||
if err == nil {
|
|
||||||
bitDepth = quality.BitDepth
|
|
||||||
sampleRate = quality.SampleRate
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsLRC := ""
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
|
||||||
}
|
|
||||||
|
|
||||||
return AmazonDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: bitDepth,
|
|
||||||
SampleRate: sampleRate,
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
ReleaseDate: req.ReleaseDate,
|
|
||||||
TrackNumber: actualTrackNum,
|
|
||||||
DiscNumber: actualDiscNum,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
LyricsLRC: lyricsLRC,
|
|
||||||
DecryptionKey: decryptionKey,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestExtractAmazonASIN(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
url string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "prefers trackAsin over albumAsin",
|
|
||||||
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
|
||||||
want: "B0TRACK456",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extract from tracks path",
|
|
||||||
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
|
||||||
want: "B0CYQHGWZJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extract from plain query asin",
|
|
||||||
url: "https://example.com/?asin=B0CYQHGWZJ",
|
|
||||||
want: "B0CYQHGWZJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fallback regex",
|
|
||||||
url: "https://example.com/path/B0CYQHGWZJ",
|
|
||||||
want: "B0CYQHGWZJ",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid url",
|
|
||||||
url: "https://music.amazon.com/tracks/not-valid",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := extractAmazonASIN(tt.url)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APEv2 tag format constants.
|
||||||
|
const (
|
||||||
|
apeTagPreamble = "APETAGEX"
|
||||||
|
apeTagHeaderSize = 32
|
||||||
|
apeTagVersion2 = 2000
|
||||||
|
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
||||||
|
apeTagFlagReadOnly = 1 << 0
|
||||||
|
// Item flags: bits 1-2 encode content type
|
||||||
|
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
|
||||||
|
apeItemFlagBinary = 1 << 1 // 01: binary data
|
||||||
|
apeItemFlagLink = 2 << 1 // 10: external link
|
||||||
|
)
|
||||||
|
|
||||||
|
// APETagItem represents a single key-value item in an APEv2 tag.
|
||||||
|
type APETagItem struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
Flags uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// APETag represents a complete APEv2 tag block.
|
||||||
|
type APETag struct {
|
||||||
|
Version uint32
|
||||||
|
Items []APETagItem
|
||||||
|
ReadOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAPETags reads APEv2 tags from a file.
|
||||||
|
// APEv2 tags are typically appended at the end of the file.
|
||||||
|
// The layout is: [audio data] [APEv2 header (optional)] [items...] [APEv2 footer]
|
||||||
|
// We locate the footer first (last 32 bytes), then read the tag block.
|
||||||
|
func ReadAPETags(filePath string) (*APETag, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||||||
|
}
|
||||||
|
fileSize := fi.Size()
|
||||||
|
|
||||||
|
if fileSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find APE tag footer at the end of file.
|
||||||
|
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||||
|
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry: skip ID3v1 tag (128 bytes) if present
|
||||||
|
if fileSize > apeTagHeaderSize+128 {
|
||||||
|
tag, err = readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize-128)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no APEv2 tag found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAPETagAtOffset(f *os.File, fileSize, footerOffset int64) (*APETag, error) {
|
||||||
|
if footerOffset < 0 || footerOffset+apeTagHeaderSize > fileSize {
|
||||||
|
return nil, fmt.Errorf("invalid footer offset")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
|
if _, err := f.ReadAt(footer, footerOffset); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(footer[0:8]) != apeTagPreamble {
|
||||||
|
return nil, fmt.Errorf("APE preamble not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||||
|
tagSize := binary.LittleEndian.Uint32(footer[12:16]) // size of items + footer (32 bytes)
|
||||||
|
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||||
|
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||||
|
|
||||||
|
if version != apeTagVersion2 && version != 1000 {
|
||||||
|
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||||
|
}
|
||||||
|
if tagSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||||
|
}
|
||||||
|
if itemCount > 1000 {
|
||||||
|
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should be the footer (bit 29 clear)
|
||||||
|
isHeader := (flags & apeTagFlagHeader) != 0
|
||||||
|
if isHeader {
|
||||||
|
return nil, fmt.Errorf("expected APE footer but found header")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagSize includes items + footer (32 bytes), but NOT the header.
|
||||||
|
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||||
|
if itemsSize < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid APE tag: items size negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsOffset := footerOffset - itemsSize
|
||||||
|
if itemsOffset < 0 {
|
||||||
|
return nil, fmt.Errorf("APE tag items extend before file start")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsData := make([]byte, itemsSize)
|
||||||
|
if _, err := f.ReadAt(itemsData, itemsOffset); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &APETag{
|
||||||
|
Version: version,
|
||||||
|
Items: items,
|
||||||
|
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPEItems(data []byte, count int) ([]APETagItem, error) {
|
||||||
|
items := make([]APETagItem, 0, count)
|
||||||
|
pos := 0
|
||||||
|
|
||||||
|
for i := 0; i < count && pos < len(data); i++ {
|
||||||
|
if pos+8 > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
valueSize := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
|
||||||
|
itemFlags := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
|
||||||
|
pos += 8
|
||||||
|
|
||||||
|
// Key is null-terminated ASCII (2-255 bytes, case-insensitive)
|
||||||
|
keyEnd := pos
|
||||||
|
for keyEnd < len(data) && data[keyEnd] != 0 {
|
||||||
|
keyEnd++
|
||||||
|
}
|
||||||
|
if keyEnd >= len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
key := string(data[pos:keyEnd])
|
||||||
|
pos = keyEnd + 1
|
||||||
|
|
||||||
|
if pos+valueSize > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
value := string(data[pos : pos+valueSize])
|
||||||
|
pos += valueSize
|
||||||
|
|
||||||
|
items = append(items, APETagItem{
|
||||||
|
Key: key,
|
||||||
|
Value: value,
|
||||||
|
Flags: itemFlags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAPETags writes APEv2 tags to the end of a file.
|
||||||
|
// If the file already has APEv2 tags, they are replaced.
|
||||||
|
// The tag is written with both header and footer.
|
||||||
|
func WriteAPETags(filePath string, tag *APETag) error {
|
||||||
|
existingSize, err := findExistingAPETagSize(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check existing APE tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagData, err := marshalAPETag(tag)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal APE tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingSize > 0 {
|
||||||
|
fi, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat file: %w", err)
|
||||||
|
}
|
||||||
|
newSize := fi.Size() - int64(existingSize)
|
||||||
|
if err := os.Truncate(filePath, newSize); err != nil {
|
||||||
|
return fmt.Errorf("failed to truncate existing APE tag: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file for writing: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := f.Write(tagData); err != nil {
|
||||||
|
return fmt.Errorf("failed to write APE tag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findExistingAPETagSize returns the total size of an existing APE tag
|
||||||
|
// (header + items + footer) at the end of the file, or 0 if none exists.
|
||||||
|
func findExistingAPETagSize(filePath string) (int64, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
fileSize := fi.Size()
|
||||||
|
|
||||||
|
offsets := []int64{fileSize - apeTagHeaderSize}
|
||||||
|
if fileSize > apeTagHeaderSize+128 {
|
||||||
|
offsets = append(offsets, fileSize-apeTagHeaderSize-128)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, offset := range offsets {
|
||||||
|
if offset < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
|
if _, err := f.ReadAt(footer, offset); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(footer[0:8]) != apeTagPreamble {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||||
|
if (flags & apeTagFlagHeader) != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||||
|
|
||||||
|
// Check if there's also a header (tagSize only covers items + footer)
|
||||||
|
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||||
|
totalSize := tagSize
|
||||||
|
if hasHeader {
|
||||||
|
totalSize += apeTagHeaderSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include any trailing data after the footer (e.g. ID3v1 128-byte tag).
|
||||||
|
// When truncating, we must remove the APE tag AND everything after it.
|
||||||
|
trailingBytes := fileSize - (offset + apeTagHeaderSize)
|
||||||
|
totalSize += trailingBytes
|
||||||
|
|
||||||
|
return totalSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalAPETag serializes an APETag into bytes (header + items + footer).
|
||||||
|
func marshalAPETag(tag *APETag) ([]byte, error) {
|
||||||
|
if tag == nil || len(tag.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty APE tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsData []byte
|
||||||
|
for _, item := range tag.Items {
|
||||||
|
keyBytes := []byte(item.Key)
|
||||||
|
valueBytes := []byte(item.Value)
|
||||||
|
|
||||||
|
// 4 bytes: value size (LE)
|
||||||
|
sizeBuf := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(sizeBuf, uint32(len(valueBytes)))
|
||||||
|
|
||||||
|
// 4 bytes: item flags (LE)
|
||||||
|
flagsBuf := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(flagsBuf, item.Flags)
|
||||||
|
|
||||||
|
itemsData = append(itemsData, sizeBuf...)
|
||||||
|
itemsData = append(itemsData, flagsBuf...)
|
||||||
|
itemsData = append(itemsData, keyBytes...)
|
||||||
|
itemsData = append(itemsData, 0)
|
||||||
|
itemsData = append(itemsData, valueBytes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagSize = items data + footer (32 bytes)
|
||||||
|
tagSize := uint32(len(itemsData) + apeTagHeaderSize)
|
||||||
|
itemCount := uint32(len(tag.Items))
|
||||||
|
|
||||||
|
version := uint32(apeTagVersion2)
|
||||||
|
if tag.Version != 0 {
|
||||||
|
version = tag.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
// flags: bit 29 = 1 (is header), bit 31 = 1 (contains header)
|
||||||
|
headerFlags := uint32(apeTagFlagHeader | (1 << 31))
|
||||||
|
header := buildAPEHeaderFooter(version, tagSize, itemCount, headerFlags)
|
||||||
|
|
||||||
|
// flags: bit 29 = 0 (is footer), bit 31 = 1 (contains header)
|
||||||
|
footerFlags := uint32(1 << 31)
|
||||||
|
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||||
|
|
||||||
|
// Final layout: header + items + footer
|
||||||
|
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||||
|
result = append(result, header...)
|
||||||
|
result = append(result, itemsData...)
|
||||||
|
result = append(result, footer...)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAPEHeaderFooter(version, tagSize, itemCount, flags uint32) []byte {
|
||||||
|
buf := make([]byte, apeTagHeaderSize)
|
||||||
|
copy(buf[0:8], apeTagPreamble)
|
||||||
|
binary.LittleEndian.PutUint32(buf[8:12], version)
|
||||||
|
binary.LittleEndian.PutUint32(buf[12:16], tagSize)
|
||||||
|
binary.LittleEndian.PutUint32(buf[16:20], itemCount)
|
||||||
|
binary.LittleEndian.PutUint32(buf[20:24], flags)
|
||||||
|
// bytes 24-31 are reserved (zeros)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// APETagToAudioMetadata converts an APETag to our unified AudioMetadata struct.
|
||||||
|
func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
||||||
|
if tag == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{}
|
||||||
|
for _, item := range tag.Items {
|
||||||
|
key := strings.ToUpper(strings.TrimSpace(item.Key))
|
||||||
|
value := strings.TrimSpace(item.Value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "TITLE":
|
||||||
|
metadata.Title = value
|
||||||
|
case "ARTIST":
|
||||||
|
metadata.Artist = value
|
||||||
|
case "ALBUM":
|
||||||
|
metadata.Album = value
|
||||||
|
case "ALBUMARTIST", "ALBUM ARTIST":
|
||||||
|
metadata.AlbumArtist = value
|
||||||
|
case "GENRE":
|
||||||
|
metadata.Genre = value
|
||||||
|
case "YEAR":
|
||||||
|
metadata.Year = value
|
||||||
|
case "DATE":
|
||||||
|
metadata.Date = value
|
||||||
|
case "TRACK", "TRACKNUMBER":
|
||||||
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
|
case "DISC", "DISCNUMBER":
|
||||||
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
|
case "ISRC":
|
||||||
|
metadata.ISRC = value
|
||||||
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
|
if metadata.Lyrics == "" {
|
||||||
|
metadata.Lyrics = value
|
||||||
|
}
|
||||||
|
case "LABEL", "PUBLISHER":
|
||||||
|
metadata.Label = value
|
||||||
|
case "COPYRIGHT":
|
||||||
|
metadata.Copyright = value
|
||||||
|
case "COMPOSER":
|
||||||
|
metadata.Composer = value
|
||||||
|
case "COMMENT":
|
||||||
|
metadata.Comment = value
|
||||||
|
case "REPLAYGAIN_TRACK_GAIN":
|
||||||
|
metadata.ReplayGainTrackGain = value
|
||||||
|
case "REPLAYGAIN_TRACK_PEAK":
|
||||||
|
metadata.ReplayGainTrackPeak = value
|
||||||
|
case "REPLAYGAIN_ALBUM_GAIN":
|
||||||
|
metadata.ReplayGainAlbumGain = value
|
||||||
|
case "REPLAYGAIN_ALBUM_PEAK":
|
||||||
|
metadata.ReplayGainAlbumPeak = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioMetadataToAPEItems converts metadata fields to APE tag items.
|
||||||
|
func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
||||||
|
if metadata == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []APETagItem
|
||||||
|
addItem := func(key, value string) {
|
||||||
|
if value != "" {
|
||||||
|
items = append(items, APETagItem{Key: key, Value: value})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addItem("Title", metadata.Title)
|
||||||
|
addItem("Artist", metadata.Artist)
|
||||||
|
addItem("Album", metadata.Album)
|
||||||
|
addItem("Album Artist", metadata.AlbumArtist)
|
||||||
|
addItem("Genre", metadata.Genre)
|
||||||
|
if metadata.Date != "" {
|
||||||
|
addItem("Year", metadata.Date)
|
||||||
|
} else if metadata.Year != "" {
|
||||||
|
addItem("Year", metadata.Year)
|
||||||
|
}
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||||
|
}
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||||
|
}
|
||||||
|
addItem("ISRC", metadata.ISRC)
|
||||||
|
addItem("Lyrics", metadata.Lyrics)
|
||||||
|
addItem("Label", metadata.Label)
|
||||||
|
addItem("Copyright", metadata.Copyright)
|
||||||
|
addItem("Composer", metadata.Composer)
|
||||||
|
addItem("Comment", metadata.Comment)
|
||||||
|
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
|
||||||
|
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
|
||||||
|
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
|
||||||
|
addItem("REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// apeKeysFromFields builds a set of upper-case APE tag keys corresponding to
|
||||||
|
// the metadata fields map sent by the editor. This is used during merge to
|
||||||
|
// ensure that even empty (cleared) fields override old values.
|
||||||
|
func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||||
|
mapping := map[string]string{
|
||||||
|
"title": "TITLE",
|
||||||
|
"artist": "ARTIST",
|
||||||
|
"album": "ALBUM",
|
||||||
|
"album_artist": "ALBUM ARTIST",
|
||||||
|
"date": "DATE",
|
||||||
|
"genre": "GENRE",
|
||||||
|
"track_number": "TRACK",
|
||||||
|
"disc_number": "DISC",
|
||||||
|
"isrc": "ISRC",
|
||||||
|
"lyrics": "LYRICS",
|
||||||
|
"label": "LABEL",
|
||||||
|
"copyright": "COPYRIGHT",
|
||||||
|
"composer": "COMPOSER",
|
||||||
|
"comment": "COMMENT",
|
||||||
|
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
|
||||||
|
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
|
||||||
|
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
|
||||||
|
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
|
||||||
|
}
|
||||||
|
result := make(map[string]struct{})
|
||||||
|
for fk, apeKey := range mapping {
|
||||||
|
if _, present := fields[fk]; present {
|
||||||
|
result[strings.ToUpper(apeKey)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Some fields have reader aliases that must also be cleared when the
|
||||||
|
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
|
||||||
|
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
||||||
|
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||||
|
if _, present := fields["date"]; present {
|
||||||
|
result["DATE"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["disc_number"]; present {
|
||||||
|
result["DISCNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["disc_total"]; present {
|
||||||
|
result["DISCNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["track_number"]; present {
|
||||||
|
result["TRACKNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["track_total"]; present {
|
||||||
|
result["TRACKNUMBER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["album_artist"]; present {
|
||||||
|
result["ALBUMARTIST"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["label"]; present {
|
||||||
|
result["PUBLISHER"] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, present := fields["lyrics"]; present {
|
||||||
|
result["UNSYNCEDLYRICS"] = struct{}{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeAPEItems overlays newItems on top of existing items.
|
||||||
|
// For each new item, if a matching key exists (case-insensitive) in existing,
|
||||||
|
// it is replaced. New keys are appended. Existing items whose keys are NOT
|
||||||
|
// in newItems are preserved (cover art, ReplayGain, custom tags, etc.).
|
||||||
|
//
|
||||||
|
// overrideKeys is an optional set of upper-case keys that should be removed
|
||||||
|
// from existing even if they do not appear in newItems. This handles field
|
||||||
|
// deletion: the caller sends an empty value which is not serialized into
|
||||||
|
// newItems, but the old value must still be dropped.
|
||||||
|
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||||
|
// Build a set of keys being updated (upper-case for case-insensitive match)
|
||||||
|
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||||
|
for k := range overrideKeys {
|
||||||
|
combined[strings.ToUpper(k)] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, item := range newItems {
|
||||||
|
combined[strings.ToUpper(item.Key)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged []APETagItem
|
||||||
|
for _, item := range existing {
|
||||||
|
if _, overwritten := combined[strings.ToUpper(item.Key)]; !overwritten {
|
||||||
|
merged = append(merged, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = append(merged, newItems...)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAPETagsFromReader reads APEv2 tags from an io.ReaderAt + size.
|
||||||
|
// This is useful for reading APE tags from files opened via SAF or other abstractions.
|
||||||
|
func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||||
|
if fileSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try footer at end of file
|
||||||
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
|
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(footer[0:8]) == apeTagPreamble {
|
||||||
|
tag, err := parseAPETagFromFooter(r, fileSize, fileSize-apeTagHeaderSize, footer)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry: skip ID3v1 tag (128 bytes)
|
||||||
|
if fileSize > apeTagHeaderSize+128 {
|
||||||
|
offset := fileSize - apeTagHeaderSize - 128
|
||||||
|
if _, err := r.ReadAt(footer, offset); err == nil {
|
||||||
|
if string(footer[0:8]) == apeTagPreamble {
|
||||||
|
tag, err := parseAPETagFromFooter(r, fileSize, offset, footer)
|
||||||
|
if err == nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no APEv2 tag found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
|
||||||
|
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||||
|
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
||||||
|
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||||
|
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||||
|
|
||||||
|
if version != apeTagVersion2 && version != 1000 {
|
||||||
|
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||||
|
}
|
||||||
|
if tagSize < apeTagHeaderSize {
|
||||||
|
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||||
|
}
|
||||||
|
if itemCount > 1000 {
|
||||||
|
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||||
|
}
|
||||||
|
if (flags & apeTagFlagHeader) != 0 {
|
||||||
|
return nil, fmt.Errorf("expected footer, found header")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||||
|
itemsOffset := footerOffset - itemsSize
|
||||||
|
if itemsOffset < 0 {
|
||||||
|
return nil, fmt.Errorf("APE items extend before file start")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsData := make([]byte, itemsSize)
|
||||||
|
if _, err := r.ReadAt(itemsData, itemsOffset); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &APETag{
|
||||||
|
Version: version,
|
||||||
|
Items: items,
|
||||||
|
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AudioMetadata represents common audio file metadata
|
|
||||||
type AudioMetadata struct {
|
type AudioMetadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
@@ -21,16 +21,22 @@ type AudioMetadata struct {
|
|||||||
Year string
|
Year string
|
||||||
Date string
|
Date string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
|
TotalTracks int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
|
TotalDiscs int
|
||||||
ISRC string
|
ISRC string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
Copyright string
|
||||||
Composer string
|
Composer string
|
||||||
Comment string
|
Comment string
|
||||||
|
// ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831")
|
||||||
|
ReplayGainTrackGain string
|
||||||
|
ReplayGainTrackPeak string
|
||||||
|
ReplayGainAlbumGain string
|
||||||
|
ReplayGainAlbumPeak string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MP3Quality represents MP3 specific quality info
|
|
||||||
type MP3Quality struct {
|
type MP3Quality struct {
|
||||||
SampleRate int
|
SampleRate int
|
||||||
BitDepth int
|
BitDepth int
|
||||||
@@ -38,7 +44,6 @@ type MP3Quality struct {
|
|||||||
Bitrate int
|
Bitrate int
|
||||||
}
|
}
|
||||||
|
|
||||||
// OggQuality represents Ogg/Opus specific quality info
|
|
||||||
type OggQuality struct {
|
type OggQuality struct {
|
||||||
SampleRate int
|
SampleRate int
|
||||||
BitDepth int
|
BitDepth int
|
||||||
@@ -46,10 +51,6 @@ type OggQuality struct {
|
|||||||
Bitrate int // estimated bitrate in bps
|
Bitrate int // estimated bitrate in bps
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ID3 Tag Reading (MP3)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
|||||||
case "TCO":
|
case "TCO":
|
||||||
metadata.Genre = cleanGenre(value)
|
metadata.Genre = cleanGenre(value)
|
||||||
case "TRK":
|
case "TRK":
|
||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
case "TPA":
|
case "TPA":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
case "TCM":
|
case "TCM":
|
||||||
metadata.Composer = value
|
metadata.Composer = value
|
||||||
case "TPB":
|
case "TPB":
|
||||||
@@ -293,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
case "TCON":
|
case "TCON":
|
||||||
metadata.Genre = cleanGenre(value)
|
metadata.Genre = cleanGenre(value)
|
||||||
case "TRCK":
|
case "TRCK":
|
||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
case "TPOS":
|
case "TPOS":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
case "TSRC":
|
case "TSRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "TCOM":
|
case "TCOM":
|
||||||
@@ -317,6 +318,17 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
metadata.Lyrics = userValue
|
metadata.Lyrics = userValue
|
||||||
}
|
}
|
||||||
|
upperDesc := strings.ToUpper(desc)
|
||||||
|
switch upperDesc {
|
||||||
|
case "REPLAYGAIN_TRACK_GAIN":
|
||||||
|
metadata.ReplayGainTrackGain = userValue
|
||||||
|
case "REPLAYGAIN_TRACK_PEAK":
|
||||||
|
metadata.ReplayGainTrackPeak = userValue
|
||||||
|
case "REPLAYGAIN_ALBUM_GAIN":
|
||||||
|
metadata.ReplayGainAlbumGain = userValue
|
||||||
|
case "REPLAYGAIN_ALBUM_PEAK":
|
||||||
|
metadata.ReplayGainAlbumPeak = userValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pos += 10 + frameSize
|
pos += 10 + frameSize
|
||||||
@@ -344,7 +356,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
|
|||||||
Year: strings.TrimRight(string(tag[93:97]), " \x00"),
|
Year: strings.TrimRight(string(tag[93:97]), " \x00"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ID3v1.1 track number (if byte 125 is 0 and byte 126 is not)
|
|
||||||
if tag[125] == 0 && tag[126] != 0 {
|
if tag[125] == 0 && tag[126] != 0 {
|
||||||
metadata.TrackNumber = int(tag[126])
|
metadata.TrackNumber = int(tag[126])
|
||||||
}
|
}
|
||||||
@@ -379,27 +390,23 @@ func extractTextFrame(data []byte) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractCommentFrame parses an ID3v2 COMM frame.
|
|
||||||
// Format: encoding(1) + language(3) + description(null-terminated) + text
|
|
||||||
func extractCommentFrame(data []byte) string {
|
func extractCommentFrame(data []byte) string {
|
||||||
if len(data) < 5 {
|
if len(data) < 5 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
encoding := data[0]
|
encoding := data[0]
|
||||||
// skip 3-byte language code
|
|
||||||
rest := data[4:]
|
rest := data[4:]
|
||||||
|
|
||||||
// find null terminator separating description from text
|
|
||||||
var text []byte
|
var text []byte
|
||||||
switch encoding {
|
switch encoding {
|
||||||
case 1, 2: // UTF-16 variants use double-null terminator
|
case 1, 2:
|
||||||
for i := 0; i+1 < len(rest); i += 2 {
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
if rest[i] == 0 && rest[i+1] == 0 {
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
text = rest[i+2:]
|
text = rest[i+2:]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: // ISO-8859-1 or UTF-8
|
default:
|
||||||
idx := bytes.IndexByte(rest, 0)
|
idx := bytes.IndexByte(rest, 0)
|
||||||
if idx >= 0 && idx+1 < len(rest) {
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
text = rest[idx+1:]
|
text = rest[idx+1:]
|
||||||
@@ -412,33 +419,30 @@ func extractCommentFrame(data []byte) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// re-prepend encoding byte so extractTextFrame can decode properly
|
|
||||||
framed := make([]byte, 1+len(text))
|
framed := make([]byte, 1+len(text))
|
||||||
framed[0] = encoding
|
framed[0] = encoding
|
||||||
copy(framed[1:], text)
|
copy(framed[1:], text)
|
||||||
return extractTextFrame(framed)
|
return extractTextFrame(framed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
|
|
||||||
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
|
|
||||||
func extractLyricsFrame(data []byte) string {
|
func extractLyricsFrame(data []byte) string {
|
||||||
if len(data) < 5 {
|
if len(data) < 5 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
encoding := data[0]
|
encoding := data[0]
|
||||||
rest := data[4:] // skip 3-byte language code
|
rest := data[4:]
|
||||||
|
|
||||||
var text []byte
|
var text []byte
|
||||||
switch encoding {
|
switch encoding {
|
||||||
case 1, 2: // UTF-16 variants use double-null terminator
|
case 1, 2:
|
||||||
for i := 0; i+1 < len(rest); i += 2 {
|
for i := 0; i+1 < len(rest); i += 2 {
|
||||||
if rest[i] == 0 && rest[i+1] == 0 {
|
if rest[i] == 0 && rest[i+1] == 0 {
|
||||||
text = rest[i+2:]
|
text = rest[i+2:]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: // ISO-8859-1 or UTF-8
|
default:
|
||||||
idx := bytes.IndexByte(rest, 0)
|
idx := bytes.IndexByte(rest, 0)
|
||||||
if idx >= 0 && idx+1 < len(rest) {
|
if idx >= 0 && idx+1 < len(rest) {
|
||||||
text = rest[idx+1:]
|
text = rest[idx+1:]
|
||||||
@@ -457,8 +461,6 @@ func extractLyricsFrame(data []byte) string {
|
|||||||
return extractTextFrame(framed)
|
return extractTextFrame(framed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
|
|
||||||
// encoding(1) + description + separator + value.
|
|
||||||
func extractUserTextFrame(data []byte) (string, string) {
|
func extractUserTextFrame(data []byte) (string, string) {
|
||||||
if len(data) < 2 {
|
if len(data) < 2 {
|
||||||
return "", ""
|
return "", ""
|
||||||
@@ -469,7 +471,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
|
|
||||||
var descRaw, valueRaw []byte
|
var descRaw, valueRaw []byte
|
||||||
switch encoding {
|
switch encoding {
|
||||||
case 1, 2: // UTF-16 variants
|
case 1, 2:
|
||||||
for i := 0; i+1 < len(payload); i += 2 {
|
for i := 0; i+1 < len(payload); i += 2 {
|
||||||
if payload[i] == 0 && payload[i+1] == 0 {
|
if payload[i] == 0 && payload[i+1] == 0 {
|
||||||
descRaw = payload[:i]
|
descRaw = payload[:i]
|
||||||
@@ -477,7 +479,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default: // ISO-8859-1 or UTF-8
|
default:
|
||||||
idx := bytes.IndexByte(payload, 0)
|
idx := bytes.IndexByte(payload, 0)
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
descRaw = payload[:idx]
|
descRaw = payload[:idx]
|
||||||
@@ -504,7 +506,13 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
|
|
||||||
func isLyricsDescription(description string) bool {
|
func isLyricsDescription(description string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(description)) {
|
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||||
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
case
|
||||||
|
"lyrics",
|
||||||
|
"lyric",
|
||||||
|
"unsyncedlyrics",
|
||||||
|
"unsynced lyrics",
|
||||||
|
"uslt",
|
||||||
|
"lrc":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -574,14 +582,28 @@ func cleanGenre(genre string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseTrackNumber(s string) int {
|
func parseTrackNumber(s string) int {
|
||||||
s = strings.TrimSpace(s)
|
num, _ := parseIndexPair(s)
|
||||||
if idx := strings.Index(s, "/"); idx > 0 {
|
|
||||||
s = s[:idx]
|
|
||||||
}
|
|
||||||
num, _ := strconv.Atoi(s)
|
|
||||||
return num
|
return num
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseIndexPair(s string) (int, int) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
first := s
|
||||||
|
second := ""
|
||||||
|
if idx := strings.Index(s, "/"); idx > 0 {
|
||||||
|
first = s[:idx]
|
||||||
|
second = s[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
num, _ := strconv.Atoi(strings.TrimSpace(first))
|
||||||
|
total, _ := strconv.Atoi(strings.TrimSpace(second))
|
||||||
|
return num, total
|
||||||
|
}
|
||||||
|
|
||||||
func removeUnsync(data []byte) []byte {
|
func removeUnsync(data []byte) []byte {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return data
|
return data
|
||||||
@@ -665,7 +687,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
|
|
||||||
file.Seek(audioStart, io.SeekStart)
|
file.Seek(audioStart, io.SeekStart)
|
||||||
|
|
||||||
// Find first valid MP3 frame sync
|
|
||||||
frameHeader := make([]byte, 4)
|
frameHeader := make([]byte, 4)
|
||||||
var frameStart int64 = -1
|
var frameStart int64 = -1
|
||||||
for i := 0; i < 10000; i++ {
|
for i := 0; i < 10000; i++ {
|
||||||
@@ -692,8 +713,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
||||||
channelMode := (frameHeader[3] >> 6) & 0x03
|
channelMode := (frameHeader[3] >> 6) & 0x03
|
||||||
|
|
||||||
// Sample rate tables: [version][index]
|
|
||||||
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
|
|
||||||
sampleRates := [][]int{
|
sampleRates := [][]int{
|
||||||
{11025, 12000, 8000},
|
{11025, 12000, 8000},
|
||||||
{0, 0, 0},
|
{0, 0, 0},
|
||||||
@@ -704,15 +723,12 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bitrate tables for all MPEG versions and layers
|
|
||||||
// MPEG1 Layer III
|
|
||||||
if version == 3 && layer == 1 {
|
if version == 3 && layer == 1 {
|
||||||
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
||||||
if bitrateIdx < 16 {
|
if bitrateIdx < 16 {
|
||||||
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MPEG2/2.5 Layer III
|
|
||||||
if (version == 0 || version == 2) && layer == 1 {
|
if (version == 0 || version == 2) && layer == 1 {
|
||||||
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
|
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
|
||||||
if bitrateIdx < 16 {
|
if bitrateIdx < 16 {
|
||||||
@@ -720,14 +736,11 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine samples per frame for duration calculation
|
|
||||||
samplesPerFrame := 1152 // MPEG1 Layer III
|
samplesPerFrame := 1152 // MPEG1 Layer III
|
||||||
if version == 0 || version == 2 {
|
if version == 0 || version == 2 {
|
||||||
samplesPerFrame = 576 // MPEG2/2.5 Layer III
|
samplesPerFrame = 576 // MPEG2/2.5 Layer III
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to read Xing/VBRI header from the first frame for VBR info
|
|
||||||
// Xing header offset depends on MPEG version and channel mode
|
|
||||||
var xingOffset int
|
var xingOffset int
|
||||||
if version == 3 { // MPEG1
|
if version == 3 { // MPEG1
|
||||||
if channelMode == 3 { // Mono
|
if channelMode == 3 { // Mono
|
||||||
@@ -743,7 +756,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read enough of the first frame to find Xing/VBRI header
|
|
||||||
xingBuf := make([]byte, 200)
|
xingBuf := make([]byte, 200)
|
||||||
file.Seek(frameStart+4, io.SeekStart)
|
file.Seek(frameStart+4, io.SeekStart)
|
||||||
n, _ := io.ReadFull(file, xingBuf)
|
n, _ := io.ReadFull(file, xingBuf)
|
||||||
@@ -753,7 +765,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
vbrBytes := int64(0)
|
vbrBytes := int64(0)
|
||||||
isVBR := false
|
isVBR := false
|
||||||
|
|
||||||
// Check for Xing/Info header
|
|
||||||
if xingOffset+8 <= n {
|
if xingOffset+8 <= n {
|
||||||
tag := string(xingBuf[xingOffset : xingOffset+4])
|
tag := string(xingBuf[xingOffset : xingOffset+4])
|
||||||
if tag == "Xing" || tag == "Info" {
|
if tag == "Xing" || tag == "Info" {
|
||||||
@@ -772,7 +783,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for VBRI header (always at offset 32 from frame start + 4)
|
|
||||||
if !isVBR && 36+26 <= n {
|
if !isVBR && 36+26 <= n {
|
||||||
if string(xingBuf[32:36]) == "VBRI" {
|
if string(xingBuf[32:36]) == "VBRI" {
|
||||||
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
|
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
|
||||||
@@ -784,11 +794,9 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
|
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
|
||||||
// Accurate duration from total frames
|
|
||||||
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
|
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
|
||||||
quality.Duration = int(totalSamples / int64(quality.SampleRate))
|
quality.Duration = int(totalSamples / int64(quality.SampleRate))
|
||||||
|
|
||||||
// Accurate average bitrate
|
|
||||||
if vbrBytes > 0 && quality.Duration > 0 {
|
if vbrBytes > 0 && quality.Duration > 0 {
|
||||||
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
|
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
|
||||||
} else if quality.Duration > 0 {
|
} else if quality.Duration > 0 {
|
||||||
@@ -796,7 +804,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
|||||||
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
|
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
|
||||||
}
|
}
|
||||||
} else if quality.Bitrate > 0 {
|
} else if quality.Bitrate > 0 {
|
||||||
// CBR fallback: estimate duration from file size and frame bitrate
|
|
||||||
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
|
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
|
||||||
if audioSize > 0 {
|
if audioSize > 0 {
|
||||||
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
||||||
@@ -980,8 +987,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reader := bytes.NewReader(data)
|
reader := bytes.NewReader(data)
|
||||||
|
artistValues := make([]string, 0, 1)
|
||||||
|
albumArtistValues := make([]string, 0, 1)
|
||||||
|
|
||||||
// Read vendor string length
|
|
||||||
var vendorLen uint32
|
var vendorLen uint32
|
||||||
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
||||||
return
|
return
|
||||||
@@ -1010,8 +1018,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
if commentLen > remaining {
|
if commentLen > remaining {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// Large comment entries are typically METADATA_BLOCK_PICTURE.
|
|
||||||
// Skip them so we can continue parsing normal text tags after/before.
|
|
||||||
if commentLen > 512*1024 {
|
if commentLen > 512*1024 {
|
||||||
reader.Seek(int64(commentLen), io.SeekCurrent)
|
reader.Seek(int64(commentLen), io.SeekCurrent)
|
||||||
continue
|
continue
|
||||||
@@ -1034,9 +1040,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
case "TITLE":
|
case "TITLE":
|
||||||
metadata.Title = value
|
metadata.Title = value
|
||||||
case "ARTIST":
|
case "ARTIST":
|
||||||
metadata.Artist = value
|
artistValues = append(artistValues, value)
|
||||||
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
||||||
metadata.AlbumArtist = value
|
albumArtistValues = append(albumArtistValues, value)
|
||||||
case "ALBUM":
|
case "ALBUM":
|
||||||
metadata.Album = value
|
metadata.Album = value
|
||||||
case "DATE", "YEAR":
|
case "DATE", "YEAR":
|
||||||
@@ -1047,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
case "GENRE":
|
case "GENRE":
|
||||||
metadata.Genre = value
|
metadata.Genre = value
|
||||||
case "TRACKNUMBER", "TRACK":
|
case "TRACKNUMBER", "TRACK":
|
||||||
metadata.TrackNumber = parseTrackNumber(value)
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||||
case "DISCNUMBER", "DISC":
|
case "DISCNUMBER", "DISC":
|
||||||
metadata.DiscNumber = parseTrackNumber(value)
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||||
case "ISRC":
|
case "ISRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "COMPOSER":
|
case "COMPOSER":
|
||||||
@@ -1064,8 +1070,23 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
metadata.Label = value
|
metadata.Label = value
|
||||||
case "COPYRIGHT":
|
case "COPYRIGHT":
|
||||||
metadata.Copyright = value
|
metadata.Copyright = value
|
||||||
|
case "REPLAYGAIN_TRACK_GAIN":
|
||||||
|
metadata.ReplayGainTrackGain = value
|
||||||
|
case "REPLAYGAIN_TRACK_PEAK":
|
||||||
|
metadata.ReplayGainTrackPeak = value
|
||||||
|
case "REPLAYGAIN_ALBUM_GAIN":
|
||||||
|
metadata.ReplayGainAlbumGain = value
|
||||||
|
case "REPLAYGAIN_ALBUM_PEAK":
|
||||||
|
metadata.ReplayGainAlbumPeak = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(artistValues) > 0 {
|
||||||
|
metadata.Artist = joinVorbisCommentValues(artistValues)
|
||||||
|
}
|
||||||
|
if len(albumArtistValues) > 0 {
|
||||||
|
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOggQuality(filePath string) (*OggQuality, error) {
|
func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||||
@@ -1114,7 +1135,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read granule position from the last Ogg page for accurate duration
|
|
||||||
stat, err := file.Stat()
|
stat, err := file.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return quality, nil
|
return quality, nil
|
||||||
@@ -1124,28 +1144,38 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
|||||||
granule := readLastOggGranulePosition(file, fileSize)
|
granule := readLastOggGranulePosition(file, fileSize)
|
||||||
if granule > 0 {
|
if granule > 0 {
|
||||||
if isOpus {
|
if isOpus {
|
||||||
// Opus always uses 48kHz granule position internally
|
|
||||||
totalSamples := granule - int64(preSkip)
|
totalSamples := granule - int64(preSkip)
|
||||||
if totalSamples > 0 {
|
if totalSamples > 0 {
|
||||||
quality.Duration = int(totalSamples / 48000)
|
durationSec := float64(totalSamples) / 48000.0
|
||||||
|
if durationSec > 0 {
|
||||||
|
quality.Duration = int(math.Round(durationSec))
|
||||||
|
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if quality.SampleRate > 0 {
|
} else if quality.SampleRate > 0 {
|
||||||
quality.Duration = int(granule / int64(quality.SampleRate))
|
durationSec := float64(granule) / float64(quality.SampleRate)
|
||||||
|
if durationSec > 0 {
|
||||||
|
quality.Duration = int(math.Round(durationSec))
|
||||||
|
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate average bitrate from file size and actual duration
|
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
||||||
if quality.Duration > 0 {
|
|
||||||
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
||||||
}
|
}
|
||||||
|
if quality.Duration > 24*60*60 {
|
||||||
|
quality.Duration = 0
|
||||||
|
quality.Bitrate = 0
|
||||||
|
}
|
||||||
|
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
|
||||||
|
quality.Bitrate = 0
|
||||||
|
}
|
||||||
|
|
||||||
return quality, nil
|
return quality, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLastOggGranulePosition seeks to the end of the file and scans backwards
|
|
||||||
// to find the last Ogg page, then reads its granule position (bytes 6-13).
|
|
||||||
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||||
// Read the last chunk of the file to find the last OggS sync
|
|
||||||
searchSize := int64(65536)
|
searchSize := int64(65536)
|
||||||
if searchSize > fileSize {
|
if searchSize > fileSize {
|
||||||
searchSize = fileSize
|
searchSize = fileSize
|
||||||
@@ -1162,27 +1192,35 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
|||||||
}
|
}
|
||||||
buf = buf[:n]
|
buf = buf[:n]
|
||||||
|
|
||||||
// Scan backwards for "OggS" magic
|
|
||||||
lastPageOffset := -1
|
|
||||||
for i := n - 4; i >= 0; i-- {
|
for i := n - 4; i >= 0; i-- {
|
||||||
if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' {
|
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
|
||||||
lastPageOffset = i
|
continue
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
if i+27 > n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
version := buf[i+4]
|
||||||
|
headerType := buf[i+5]
|
||||||
|
if version != 0 || headerType > 0x07 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segmentCount := int(buf[i+26])
|
||||||
|
headerLen := 27 + segmentCount
|
||||||
|
if i+headerLen > n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payloadLen := 0
|
||||||
|
for s := 0; s < segmentCount; s++ {
|
||||||
|
payloadLen += int(buf[i+27+s])
|
||||||
|
}
|
||||||
|
if i+headerLen+payloadLen > n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
||||||
}
|
}
|
||||||
|
return 0
|
||||||
if lastPageOffset < 0 || lastPageOffset+14 > n {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64)
|
|
||||||
return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// ID3v1 Genre List
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
var id3v1Genres = []string{
|
var id3v1Genres = []string{
|
||||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
||||||
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
||||||
@@ -1213,10 +1251,6 @@ var id3v1Genres = []string{
|
|||||||
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Cover Art Extraction
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1241,7 +1275,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
|||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse frames looking for APIC (Attached Picture)
|
|
||||||
pos := 0
|
pos := 0
|
||||||
var frameIDLen, headerLen int
|
var frameIDLen, headerLen int
|
||||||
if majorVersion == 2 {
|
if majorVersion == 2 {
|
||||||
@@ -1272,7 +1305,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for APIC (ID3v2.3/2.4) or PIC (ID3v2.2)
|
|
||||||
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
|
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
|
||||||
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
|
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
|
||||||
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
|
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
|
||||||
@@ -1550,7 +1582,14 @@ func base64StdDecode(dst, src []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||||
|
return extractAnyCoverArtWithHint(filePath, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext == "" {
|
||||||
|
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
||||||
|
}
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".flac":
|
case ".flac":
|
||||||
@@ -1571,7 +1610,19 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
|||||||
return extractOggCoverArt(filePath)
|
return extractOggCoverArt(filePath)
|
||||||
|
|
||||||
case ".m4a":
|
case ".m4a":
|
||||||
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
|
data, err := extractCoverFromM4A(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
mimeType := "image/jpeg"
|
||||||
|
if len(data) >= 8 &&
|
||||||
|
data[0] == 0x89 &&
|
||||||
|
data[1] == 0x50 &&
|
||||||
|
data[2] == 0x4E &&
|
||||||
|
data[3] == 0x47 {
|
||||||
|
mimeType = "image/png"
|
||||||
|
}
|
||||||
|
return data, mimeType, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||||
@@ -1579,10 +1630,28 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||||
|
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||||
|
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
||||||
|
explicitKey = strings.TrimSpace(explicitKey)
|
||||||
|
if explicitKey != "" {
|
||||||
|
return explicitKey
|
||||||
|
}
|
||||||
|
|
||||||
cacheKey := filePath
|
cacheKey := filePath
|
||||||
if stat, err := os.Stat(filePath); err == nil {
|
if stat, err := os.Stat(filePath); err == nil {
|
||||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||||
}
|
}
|
||||||
|
return cacheKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
|
||||||
|
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||||
hash := hashString(cacheKey)
|
hash := hashString(cacheKey)
|
||||||
|
|
||||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||||
@@ -1595,7 +1664,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
|||||||
return pngPath, nil
|
return pngPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
imageData, mimeType, err := extractAnyCoverArt(filePath)
|
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const explicitKey = "content://media/external/audio/media/42|123456"
|
||||||
|
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
|
||||||
|
if got != explicitKey {
|
||||||
|
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp failed: %v", err)
|
||||||
|
}
|
||||||
|
tempPath := tempFile.Name()
|
||||||
|
tempFile.Close()
|
||||||
|
defer os.Remove(tempPath)
|
||||||
|
|
||||||
|
got := resolveLibraryCoverCacheKey(tempPath, "")
|
||||||
|
if !strings.HasPrefix(got, tempPath+"|") {
|
||||||
|
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||||
|
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
return exec.Command(ffmpegPath, args...)
|
||||||
|
}
|
||||||
|
return exec.Command("ffmpeg", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := ffmpegCommand(args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
t.Skip("ffmpeg not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||||
|
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||||
|
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||||
|
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||||
|
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"sine=frequency=440:duration=1",
|
||||||
|
"-c:a",
|
||||||
|
"flac",
|
||||||
|
sourceFlac,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"color=c=red:s=32x32:d=1",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
coverPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
sourceFlac,
|
||||||
|
"-b:a",
|
||||||
|
"320k",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
baseMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
baseMp3,
|
||||||
|
"-i",
|
||||||
|
coverPath,
|
||||||
|
"-map",
|
||||||
|
"0:a",
|
||||||
|
"-map_metadata",
|
||||||
|
"-1",
|
||||||
|
"-map",
|
||||||
|
"1:0",
|
||||||
|
"-c:v:0",
|
||||||
|
"copy",
|
||||||
|
"-id3v2_version",
|
||||||
|
"3",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
"-metadata:s:v",
|
||||||
|
"title=Album cover",
|
||||||
|
"-metadata:s:v",
|
||||||
|
"comment=Cover (front)",
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
finalMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
meta, err := ReadID3Tags(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(finalMp3); err != nil {
|
||||||
|
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ const (
|
|||||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||||
|
|
||||||
|
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if maxURL != downloadURL {
|
if maxURL != downloadURL {
|
||||||
downloadURL = maxURL
|
downloadURL = maxURL
|
||||||
// Log already printed by upgradeToMaxQuality for Deezer
|
|
||||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||||
}
|
}
|
||||||
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func upgradeToMaxQuality(coverURL string) string {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify CDN upgrade
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer CDN upgrade
|
|
||||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
return upgradeDeezerCover(coverURL)
|
return upgradeDeezerCover(coverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
|
return upgradeTidalCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||||
|
return upgradeQobuzCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +111,6 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace any size pattern with 1800x1800
|
|
||||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
@@ -112,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return upgraded
|
return upgraded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func upgradeTidalCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeQobuzCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always upgrade small to medium first
|
|
||||||
result := convertSmallToMedium(imageURL)
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
|
|||||||
@@ -0,0 +1,565 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CueSheet struct {
|
||||||
|
Performer string `json:"performer"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
Tracks []CueTrack `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CueTrack struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Performer string `json:"performer"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||||
|
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CueSplitInfo struct {
|
||||||
|
CuePath string `json:"cue_path"`
|
||||||
|
AudioPath string `json:"audio_path"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Tracks []CueSplitTrack `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CueSplitTrack struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
StartSec float64 `json:"start_sec"`
|
||||||
|
EndSec float64 `json:"end_sec"` // -1 means until end of file
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
|
||||||
|
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||||
|
f, err := os.Open(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open cue file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
sheet := &CueSheet{}
|
||||||
|
var currentTrack *CueTrack
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||||
|
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
upper := strings.ToUpper(line)
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "REM ") {
|
||||||
|
matches := reRemCommand.FindStringSubmatch(line)
|
||||||
|
if len(matches) == 3 {
|
||||||
|
key := strings.ToUpper(matches[1])
|
||||||
|
value := unquoteCue(matches[2])
|
||||||
|
switch key {
|
||||||
|
case "GENRE":
|
||||||
|
sheet.Genre = value
|
||||||
|
case "DATE":
|
||||||
|
sheet.Date = value
|
||||||
|
case "COMMENT":
|
||||||
|
sheet.Comment = value
|
||||||
|
case "COMPOSER":
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Composer = value
|
||||||
|
} else {
|
||||||
|
sheet.Composer = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||||
|
value := unquoteCue(line[len("PERFORMER "):])
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Performer = value
|
||||||
|
} else {
|
||||||
|
sheet.Performer = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "TITLE ") {
|
||||||
|
value := unquoteCue(line[len("TITLE "):])
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Title = value
|
||||||
|
} else {
|
||||||
|
sheet.Title = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "FILE ") {
|
||||||
|
rest := line[len("FILE "):]
|
||||||
|
fname, ftype := parseCueFileLine(rest)
|
||||||
|
sheet.FileName = fname
|
||||||
|
sheet.FileType = ftype
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "TRACK ") {
|
||||||
|
if currentTrack != nil {
|
||||||
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
trackNum := 0
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
trackNum, _ = strconv.Atoi(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTrack = &CueTrack{
|
||||||
|
Number: trackNum,
|
||||||
|
PreGap: -1,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
indexNum, _ := strconv.Atoi(parts[1])
|
||||||
|
timeSec := parseCueTimestamp(parts[2])
|
||||||
|
switch indexNum {
|
||||||
|
case 0:
|
||||||
|
currentTrack.PreGap = timeSec
|
||||||
|
case 1:
|
||||||
|
currentTrack.StartTime = timeSec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||||
|
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||||
|
value := unquoteCue(line[len("SONGWRITER "):])
|
||||||
|
if currentTrack != nil {
|
||||||
|
currentTrack.Composer = value
|
||||||
|
} else {
|
||||||
|
sheet.Composer = value
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentTrack != nil {
|
||||||
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading cue file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sheet.Tracks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found in cue file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sheet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCueTimestamp(ts string) float64 {
|
||||||
|
parts := strings.Split(ts, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes, _ := strconv.Atoi(parts[0])
|
||||||
|
seconds, _ := strconv.Atoi(parts[1])
|
||||||
|
frames, _ := strconv.Atoi(parts[2])
|
||||||
|
|
||||||
|
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCueTimestamp(seconds float64) string {
|
||||||
|
if seconds < 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
hours := int(seconds) / 3600
|
||||||
|
mins := (int(seconds) % 3600) / 60
|
||||||
|
secs := seconds - float64(hours*3600) - float64(mins*60)
|
||||||
|
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquoteCue(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCueFileLine(rest string) (string, string) {
|
||||||
|
rest = strings.TrimSpace(rest)
|
||||||
|
|
||||||
|
var filename, ftype string
|
||||||
|
|
||||||
|
if strings.HasPrefix(rest, "\"") {
|
||||||
|
endQuote := strings.Index(rest[1:], "\"")
|
||||||
|
if endQuote >= 0 {
|
||||||
|
filename = rest[1 : endQuote+1]
|
||||||
|
remaining := strings.TrimSpace(rest[endQuote+2:])
|
||||||
|
ftype = remaining
|
||||||
|
} else {
|
||||||
|
filename = rest
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts := strings.Fields(rest)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
ftype = parts[len(parts)-1]
|
||||||
|
filename = strings.Join(parts[:len(parts)-1], " ")
|
||||||
|
} else if len(parts) == 1 {
|
||||||
|
filename = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename, strings.TrimSpace(ftype)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||||
|
cueDir := filepath.Dir(cuePath)
|
||||||
|
|
||||||
|
candidate := filepath.Join(cueDir, cueFileName)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||||
|
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||||
|
for _, ext := range commonExts {
|
||||||
|
candidate = filepath.Join(cueDir, baseName+ext)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
|
||||||
|
for _, ext := range commonExts {
|
||||||
|
candidate = filepath.Join(cueDir, cueBase+ext)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(cueDir)
|
||||||
|
if err == nil {
|
||||||
|
audioExts := map[string]bool{
|
||||||
|
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
|
||||||
|
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
|
||||||
|
}
|
||||||
|
var audioFiles []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||||
|
if audioExts[ext] {
|
||||||
|
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(audioFiles) == 1 {
|
||||||
|
return audioFiles[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
|
||||||
|
resolveDir := cuePath
|
||||||
|
if audioDir != "" {
|
||||||
|
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||||
|
}
|
||||||
|
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
||||||
|
if audioPath == "" {
|
||||||
|
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &CueSplitInfo{
|
||||||
|
CuePath: cuePath,
|
||||||
|
AudioPath: audioPath,
|
||||||
|
Album: sheet.Title,
|
||||||
|
Artist: sheet.Performer,
|
||||||
|
Genre: sheet.Genre,
|
||||||
|
Date: sheet.Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, track := range sheet.Tracks {
|
||||||
|
performer := track.Performer
|
||||||
|
if performer == "" {
|
||||||
|
performer = sheet.Performer
|
||||||
|
}
|
||||||
|
|
||||||
|
composer := track.Composer
|
||||||
|
if composer == "" {
|
||||||
|
composer = sheet.Composer
|
||||||
|
}
|
||||||
|
|
||||||
|
endSec := float64(-1)
|
||||||
|
if i+1 < len(sheet.Tracks) {
|
||||||
|
nextTrack := sheet.Tracks[i+1]
|
||||||
|
if nextTrack.PreGap >= 0 {
|
||||||
|
endSec = nextTrack.PreGap
|
||||||
|
} else {
|
||||||
|
endSec = nextTrack.StartTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Tracks = append(info.Tracks, CueSplitTrack{
|
||||||
|
Number: track.Number,
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: performer,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
Composer: composer,
|
||||||
|
StartSec: track.StartTime,
|
||||||
|
EndSec: endSec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||||
|
sheet, err := ParseCueFile(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse cue file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
sheet, err := ParseCueFile(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
return ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||||
|
cuePath,
|
||||||
|
audioDir,
|
||||||
|
virtualPathPrefix,
|
||||||
|
fileModTime,
|
||||||
|
"",
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
sheet, err := ParseCueFile(cuePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return scanCueSheetForLibrary(
|
||||||
|
cuePath,
|
||||||
|
sheet,
|
||||||
|
audioPath,
|
||||||
|
virtualPathPrefix,
|
||||||
|
fileModTime,
|
||||||
|
coverCacheKey,
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
||||||
|
if sheet == nil {
|
||||||
|
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||||
|
}
|
||||||
|
resolveBase := cuePath
|
||||||
|
if audioDir != "" {
|
||||||
|
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||||
|
}
|
||||||
|
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||||
|
if audioPath == "" {
|
||||||
|
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||||
|
}
|
||||||
|
return audioPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||||
|
if sheet == nil {
|
||||||
|
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bitDepth, sampleRate int
|
||||||
|
var totalDurationSec float64
|
||||||
|
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
||||||
|
switch audioExt {
|
||||||
|
case ".flac":
|
||||||
|
quality, qErr := GetAudioQuality(audioPath)
|
||||||
|
if qErr == nil {
|
||||||
|
bitDepth = quality.BitDepth
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
|
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ".mp3":
|
||||||
|
quality, qErr := GetMP3Quality(audioPath)
|
||||||
|
if qErr == nil {
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
totalDurationSec = float64(quality.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverPath string
|
||||||
|
libraryCoverCacheMu.RLock()
|
||||||
|
coverCacheDir := libraryCoverCacheDir
|
||||||
|
libraryCoverCacheMu.RUnlock()
|
||||||
|
if coverCacheDir != "" {
|
||||||
|
cp, err := SaveCoverToCacheWithHintAndKey(
|
||||||
|
audioPath,
|
||||||
|
"",
|
||||||
|
coverCacheDir,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
|
if err == nil && cp != "" {
|
||||||
|
coverPath = cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pathBase := cuePath
|
||||||
|
if virtualPathPrefix != "" {
|
||||||
|
pathBase = virtualPathPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
modTime := fileModTime
|
||||||
|
if modTime <= 0 {
|
||||||
|
if info, err := os.Stat(cuePath); err == nil {
|
||||||
|
modTime = info.ModTime().UnixMilli()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []LibraryScanResult
|
||||||
|
for i, track := range sheet.Tracks {
|
||||||
|
performer := track.Performer
|
||||||
|
if performer == "" {
|
||||||
|
performer = sheet.Performer
|
||||||
|
}
|
||||||
|
if performer == "" {
|
||||||
|
performer = "Unknown Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
title := track.Title
|
||||||
|
if title == "" {
|
||||||
|
title = fmt.Sprintf("Track %02d", track.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
album := sheet.Title
|
||||||
|
if album == "" {
|
||||||
|
album = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
composer := track.Composer
|
||||||
|
if composer == "" {
|
||||||
|
composer = sheet.Composer
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration int
|
||||||
|
if i+1 < len(sheet.Tracks) {
|
||||||
|
nextStart := sheet.Tracks[i+1].StartTime
|
||||||
|
if sheet.Tracks[i+1].PreGap >= 0 {
|
||||||
|
nextStart = sheet.Tracks[i+1].PreGap
|
||||||
|
}
|
||||||
|
duration = int(nextStart - track.StartTime)
|
||||||
|
} else if totalDurationSec > 0 {
|
||||||
|
duration = int(totalDurationSec - track.StartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
|
||||||
|
|
||||||
|
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
|
||||||
|
|
||||||
|
result := LibraryScanResult{
|
||||||
|
ID: id,
|
||||||
|
TrackName: title,
|
||||||
|
ArtistName: performer,
|
||||||
|
AlbumName: album,
|
||||||
|
AlbumArtist: sheet.Performer,
|
||||||
|
FilePath: virtualFilePath,
|
||||||
|
CoverPath: coverPath,
|
||||||
|
ScannedAt: scanTime,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
TrackNumber: track.Number,
|
||||||
|
TotalTracks: len(sheet.Tracks),
|
||||||
|
DiscNumber: 1,
|
||||||
|
TotalDiscs: 1,
|
||||||
|
Duration: duration,
|
||||||
|
ReleaseDate: sheet.Date,
|
||||||
|
BitDepth: bitDepth,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
Genre: sheet.Genre,
|
||||||
|
Composer: composer,
|
||||||
|
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||||
|
}
|
||||||
|
|
||||||
|
result.FileModTime = modTime
|
||||||
|
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -13,12 +13,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
deezerBaseURL = "https://api.deezer.com/2.0"
|
deezerBaseURL = "https://api.deezer.com/2.0"
|
||||||
deezerSearchURL = deezerBaseURL + "/search"
|
deezerSearchURL = deezerBaseURL + "/search"
|
||||||
deezerTrackURL = deezerBaseURL + "/track/%s"
|
deezerTrackURL = deezerBaseURL + "/track/%s"
|
||||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
deezerArtistRelatedURL = deezerBaseURL + "/artist/%s/related"
|
||||||
|
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||||
|
|
||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
@@ -195,15 +196,22 @@ type deezerAlbumSimple struct {
|
|||||||
RecordType string `json:"record_type"`
|
RecordType string `json:"record_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
// deezerTrackArtistDisplay returns the display artist string for a track,
|
||||||
artistName := track.Artist.Name
|
// preferring the Contributors list (comma-joined) when available, falling
|
||||||
|
// back to the primary Artist.Name.
|
||||||
|
func deezerTrackArtistDisplay(track deezerTrack) string {
|
||||||
if len(track.Contributors) > 0 {
|
if len(track.Contributors) > 0 {
|
||||||
names := make([]string, len(track.Contributors))
|
names := make([]string, len(track.Contributors))
|
||||||
for i, a := range track.Contributors {
|
for i, a := range track.Contributors {
|
||||||
names[i] = a.Name
|
names[i] = a.Name
|
||||||
}
|
}
|
||||||
artistName = strings.Join(names, ", ")
|
return strings.Join(names, ", ")
|
||||||
}
|
}
|
||||||
|
return track.Artist.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
|
artistName := deezerTrackArtistDisplay(track)
|
||||||
|
|
||||||
albumImage := track.Album.CoverXL
|
albumImage := track.Album.CoverXL
|
||||||
if albumImage == "" {
|
if albumImage == "" {
|
||||||
@@ -234,6 +242,8 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||||
|
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +263,7 @@ type deezerAlbumFull struct {
|
|||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
RecordType string `json:"record_type"`
|
RecordType string `json:"record_type"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
Genres struct {
|
Genres struct {
|
||||||
Data []deezerGenre `json:"data"`
|
Data []deezerGenre `json:"data"`
|
||||||
} `json:"genres"`
|
} `json:"genres"`
|
||||||
@@ -619,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
totalDiscs := 0
|
||||||
|
for _, track := range allTracks {
|
||||||
|
if track.DiskNumber > totalDiscs {
|
||||||
|
totalDiscs = track.DiskNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
albumType := album.RecordType
|
albumType := album.RecordType
|
||||||
@@ -637,7 +654,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
|
|
||||||
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: deezerTrackArtistDisplay(track),
|
||||||
Name: track.Title,
|
Name: track.Title,
|
||||||
AlbumName: album.Title,
|
AlbumName: album.Title,
|
||||||
AlbumArtist: artistName,
|
AlbumArtist: artistName,
|
||||||
@@ -647,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
TrackNumber: trackNum,
|
TrackNumber: trackNum,
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
|
TotalDiscs: totalDiscs,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
@@ -737,6 +755,10 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
Artists: artist.Name,
|
Artists: artist.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
|
||||||
|
// Fetch track counts in parallel from individual /album/{id} endpoints.
|
||||||
|
c.fetchAlbumTrackCounts(ctx, albums)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &ArtistResponsePayload{
|
result := &ArtistResponsePayload{
|
||||||
@@ -756,6 +778,123 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
|
||||||
|
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
|
||||||
|
// not include this field. Albums whose track count is already known (non-zero)
|
||||||
|
// are skipped.
|
||||||
|
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||||
|
// Find albums that need track counts
|
||||||
|
type indexedID struct {
|
||||||
|
idx int
|
||||||
|
albumID string
|
||||||
|
}
|
||||||
|
var toFetch []indexedID
|
||||||
|
for i, a := range albums {
|
||||||
|
if a.TotalTracks == 0 {
|
||||||
|
rawID := strings.TrimPrefix(a.ID, "deezer:")
|
||||||
|
if rawID != "" {
|
||||||
|
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toFetch) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxParallel = 10
|
||||||
|
sem := make(chan struct{}, maxParallel)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, item := range toFetch {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(it indexedID) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
|
||||||
|
var resp struct {
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
albums[it.idx].TotalTracks = resp.NbTracks
|
||||||
|
mu.Unlock()
|
||||||
|
}(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
||||||
|
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
|
||||||
|
if normalizedArtistID == "" {
|
||||||
|
return nil, fmt.Errorf("invalid Deezer artist ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveLimit := limit
|
||||||
|
if effectiveLimit <= 0 {
|
||||||
|
effectiveLimit = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
relatedURL := fmt.Sprintf("%s?limit=%d", fmt.Sprintf(deezerArtistRelatedURL, normalizedArtistID), effectiveLimit)
|
||||||
|
var relatedResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbFan int `json:"nb_fan"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, relatedURL, &relatedResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if relatedResp.Error != nil {
|
||||||
|
return nil, fmt.Errorf("deezer related artists error: %s", relatedResp.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]SearchArtistResult, 0, len(relatedResp.Data))
|
||||||
|
for _, artist := range relatedResp.Data {
|
||||||
|
imageURL := artist.PictureXL
|
||||||
|
if imageURL == "" {
|
||||||
|
imageURL = artist.PictureBig
|
||||||
|
}
|
||||||
|
if imageURL == "" {
|
||||||
|
imageURL = artist.PictureMedium
|
||||||
|
}
|
||||||
|
if imageURL == "" {
|
||||||
|
imageURL = artist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, SearchArtistResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: imageURL,
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -828,7 +967,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
|
|
||||||
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: deezerTrackArtistDisplay(track),
|
||||||
Name: track.Title,
|
Name: track.Title,
|
||||||
AlbumName: track.Album.Title,
|
AlbumName: track.Album.Title,
|
||||||
AlbumArtist: track.Artist.Name,
|
AlbumArtist: track.Artist.Name,
|
||||||
@@ -1021,8 +1160,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlbumExtendedMetadata struct {
|
type AlbumExtendedMetadata struct {
|
||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
|
Copyright string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||||
@@ -1053,8 +1193,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := &AlbumExtendedMetadata{
|
result := &AlbumExtendedMetadata{
|
||||||
Genre: strings.Join(genres, ", "),
|
Genre: strings.Join(genres, ", "),
|
||||||
Label: album.Label,
|
Label: album.Label,
|
||||||
|
Copyright: album.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
@@ -1066,7 +1207,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
c.maybeCleanupCachesLocked(now)
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -1115,7 +1256,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
|
|
||||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
|
||||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
@@ -1128,7 +1269,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "connection reset") ||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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()
|
||||||
@@ -34,14 +33,11 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: need to build index
|
|
||||||
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
|
||||||
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||||
mu := buildLock.(*sync.Mutex)
|
mu := buildLock.(*sync.Mutex)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
// Double-check cache after acquiring lock (another goroutine may have built it)
|
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
idx, exists = isrcIndexCache[outputDir]
|
idx, exists = isrcIndexCache[outputDir]
|
||||||
isrcIndexCacheMu.RUnlock()
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
||||||
|
result := buildDeezerExtendedMetadataResult(nil)
|
||||||
|
|
||||||
|
if result["genre"] != "" {
|
||||||
|
t.Fatalf("expected empty genre, got %q", result["genre"])
|
||||||
|
}
|
||||||
|
if result["label"] != "" {
|
||||||
|
t.Fatalf("expected empty label, got %q", result["label"])
|
||||||
|
}
|
||||||
|
if result["copyright"] != "" {
|
||||||
|
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
||||||
|
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
||||||
|
Genre: "Rock",
|
||||||
|
Label: "EMI",
|
||||||
|
Copyright: "(C) Queen",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result["genre"] != "Rock" {
|
||||||
|
t.Fatalf("unexpected genre: %q", result["genre"])
|
||||||
|
}
|
||||||
|
if result["label"] != "EMI" {
|
||||||
|
t.Fatalf("unexpected label: %q", result["label"])
|
||||||
|
}
|
||||||
|
if result["copyright"] != "(C) Queen" {
|
||||||
|
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
||||||
|
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
||||||
|
SpotifyID: "deezer:3135556",
|
||||||
|
Name: "Love Of My Life",
|
||||||
|
Artists: "Queen",
|
||||||
|
AlbumName: "A Night at the Opera",
|
||||||
|
ISRC: "GBUM71029604",
|
||||||
|
ReleaseDate: "1975-11-21",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result["spotify_id"] != "deezer:3135556" {
|
||||||
|
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
||||||
|
}
|
||||||
|
if result["id"] != "3135556" {
|
||||||
|
t.Fatalf("unexpected id: %v", result["id"])
|
||||||
|
}
|
||||||
|
if result["track_id"] != "3135556" {
|
||||||
|
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
||||||
|
}
|
||||||
|
if result["success"] != true {
|
||||||
|
t.Fatalf("expected success=true, got %v", result["success"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
|
||||||
|
|
||||||
|
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||||
|
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := GetExtensionFallbackProviderIDs(); got != nil {
|
||||||
|
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Bonus Track",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
AlbumName: "Album (Deluxe)",
|
||||||
|
AlbumArtist: "Artist",
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
TrackNumber: 14,
|
||||||
|
DiscNumber: 1,
|
||||||
|
ISRC: "REQ123",
|
||||||
|
CoverURL: "https://example.com/cover.jpg",
|
||||||
|
Genre: "Pop",
|
||||||
|
Label: "Label",
|
||||||
|
Copyright: "Copyright",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := DownloadResult{
|
||||||
|
Title: "Bonus Track",
|
||||||
|
Artist: "Artist",
|
||||||
|
Album: "Album",
|
||||||
|
ReleaseDate: "2023-12-01",
|
||||||
|
TrackNumber: 2,
|
||||||
|
DiscNumber: 9,
|
||||||
|
ISRC: "RES456",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildDownloadSuccessResponse(
|
||||||
|
req,
|
||||||
|
result,
|
||||||
|
"tidal",
|
||||||
|
"ok",
|
||||||
|
"/tmp/test.flac",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.Album != req.AlbumName {
|
||||||
|
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
|
||||||
|
}
|
||||||
|
if resp.ReleaseDate != req.ReleaseDate {
|
||||||
|
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
|
||||||
|
}
|
||||||
|
if resp.TrackNumber != req.TrackNumber {
|
||||||
|
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
|
||||||
|
}
|
||||||
|
if resp.DiscNumber != req.DiscNumber {
|
||||||
|
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
|
||||||
|
}
|
||||||
|
if resp.Artist != result.Artist {
|
||||||
|
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
|
||||||
|
}
|
||||||
|
if resp.ISRC != result.ISRC {
|
||||||
|
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
||||||
|
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||||
|
DownloadRequest{
|
||||||
|
AlbumName: "Album (Deluxe Edition)",
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
TrackNumber: 13,
|
||||||
|
DiscNumber: 2,
|
||||||
|
},
|
||||||
|
"Album",
|
||||||
|
"2023-01-01",
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if album != "Album (Deluxe Edition)" {
|
||||||
|
t.Fatalf("album = %q", album)
|
||||||
|
}
|
||||||
|
if releaseDate != "2024-01-01" {
|
||||||
|
t.Fatalf("release date = %q", releaseDate)
|
||||||
|
}
|
||||||
|
if trackNumber != 13 {
|
||||||
|
t.Fatalf("track number = %d", trackNumber)
|
||||||
|
}
|
||||||
|
if discNumber != 2 {
|
||||||
|
t.Fatalf("disc number = %d", discNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Track",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
AlbumName: "Album",
|
||||||
|
AlbumArtist: "Artist",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := DownloadResult{
|
||||||
|
Title: "Track",
|
||||||
|
Artist: "Artist",
|
||||||
|
Album: "Album",
|
||||||
|
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildDownloadSuccessResponse(
|
||||||
|
req,
|
||||||
|
result,
|
||||||
|
"qobuz",
|
||||||
|
"ok",
|
||||||
|
"/tmp/test.flac",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.CoverURL != result.CoverURL {
|
||||||
|
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Track",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := DownloadResult{
|
||||||
|
Title: "Track",
|
||||||
|
Artist: "Artist",
|
||||||
|
DecryptionKey: "00112233",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildDownloadSuccessResponse(
|
||||||
|
req,
|
||||||
|
result,
|
||||||
|
"amazon",
|
||||||
|
"ok",
|
||||||
|
"/tmp/test.m4a",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.Decryption == nil {
|
||||||
|
t.Fatal("expected decryption descriptor to be present")
|
||||||
|
}
|
||||||
|
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||||
|
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
|
||||||
|
}
|
||||||
|
if resp.Decryption.Key != result.DecryptionKey {
|
||||||
|
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
SpotifyID: "spotify-track-id",
|
||||||
|
AlbumName: "Original Album",
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
ISRC: "REQ123",
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||||
|
AlbumName: "Resolved Album",
|
||||||
|
ReleaseDate: "",
|
||||||
|
ISRC: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.ReleaseDate != "2024-01-01" {
|
||||||
|
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||||
|
}
|
||||||
|
if req.AlbumName != "Resolved Album" {
|
||||||
|
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||||
|
}
|
||||||
|
if req.ISRC != "REQ123" {
|
||||||
|
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song Title",
|
||||||
|
ArtistName: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
ReleaseDate: "",
|
||||||
|
DurationMs: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "first",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "",
|
||||||
|
ProviderID: "spotify",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "second",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "2024-03-09",
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := selectBestReEnrichTrack(req, tracks)
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected a selected track")
|
||||||
|
}
|
||||||
|
if best.ID != "second" {
|
||||||
|
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
AlbumName: "Album",
|
||||||
|
AlbumArtist: "",
|
||||||
|
ReleaseDate: "",
|
||||||
|
TrackNumber: 0,
|
||||||
|
DiscNumber: 0,
|
||||||
|
ISRC: "",
|
||||||
|
Genre: "",
|
||||||
|
Label: "",
|
||||||
|
Copyright: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||||
|
|
||||||
|
if metadata["TITLE"] != "Song" {
|
||||||
|
t.Fatalf("title = %q", metadata["TITLE"])
|
||||||
|
}
|
||||||
|
if metadata["ARTIST"] != "Artist" {
|
||||||
|
t.Fatalf("artist = %q", metadata["ARTIST"])
|
||||||
|
}
|
||||||
|
if metadata["ALBUM"] != "Album" {
|
||||||
|
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{
|
||||||
|
"ALBUMARTIST",
|
||||||
|
"DATE",
|
||||||
|
"TRACKNUMBER",
|
||||||
|
"DISCNUMBER",
|
||||||
|
"ISRC",
|
||||||
|
"GENRE",
|
||||||
|
"ORGANIZATION",
|
||||||
|
"COPYRIGHT",
|
||||||
|
"LYRICS",
|
||||||
|
"UNSYNCEDLYRICS",
|
||||||
|
} {
|
||||||
|
if _, exists := metadata[key]; exists {
|
||||||
|
t.Fatalf("did not expect key %s in metadata: %#v", key, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Sign of the Times",
|
||||||
|
ArtistName: "Unknown Artist",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
}
|
||||||
|
|
||||||
|
query := buildReEnrichSearchQuery(req)
|
||||||
|
if query != "Sign of the Times" {
|
||||||
|
t.Fatalf("query = %q", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = reEnrichRequest{
|
||||||
|
TrackName: "Unknown Title",
|
||||||
|
ArtistName: "Unknown Artist",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
}
|
||||||
|
query = buildReEnrichSearchQuery(req)
|
||||||
|
if query != "Harry Styles" {
|
||||||
|
t.Fatalf("fallback album query = %q", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
|
||||||
|
req := reEnrichRequest{}
|
||||||
|
|
||||||
|
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||||
|
Name: "Resolved Song",
|
||||||
|
Artists: "Resolved Artist",
|
||||||
|
TrackNumber: 7,
|
||||||
|
TotalTracks: 12,
|
||||||
|
DiscNumber: 2,
|
||||||
|
TotalDiscs: 3,
|
||||||
|
Composer: "Composer",
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.TrackNumber != 7 || req.TotalTracks != 12 {
|
||||||
|
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
|
||||||
|
}
|
||||||
|
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
|
||||||
|
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
|
||||||
|
}
|
||||||
|
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
|
||||||
|
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
|
||||||
|
}
|
||||||
|
if req.Composer != "Composer" {
|
||||||
|
t.Fatalf("composer = %q", req.Composer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackNumber: 7,
|
||||||
|
TotalTracks: 12,
|
||||||
|
DiscNumber: 2,
|
||||||
|
TotalDiscs: 3,
|
||||||
|
Composer: "Composer",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||||
|
|
||||||
|
if metadata["TRACKNUMBER"] != "7/12" {
|
||||||
|
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
|
||||||
|
}
|
||||||
|
if metadata["DISCNUMBER"] != "2/3" {
|
||||||
|
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
|
||||||
|
}
|
||||||
|
if metadata["COMPOSER"] != "Composer" {
|
||||||
|
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,40 +43,101 @@ func compareVersions(v1, v2 string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadedExtension struct {
|
type loadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
Enabled bool `json:"enabled"`
|
runtime *extensionRuntime
|
||||||
Error string `json:"error,omitempty"`
|
initialized bool
|
||||||
DataDir string `json:"data_dir"`
|
Enabled bool `json:"enabled"`
|
||||||
SourceDir string `json:"source_dir"`
|
Error string `json:"error,omitempty"`
|
||||||
IconPath string `json:"icon_path"`
|
DataDir string `json:"data_dir"`
|
||||||
|
SourceDir string `json:"source_dir"`
|
||||||
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionManager struct {
|
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||||
|
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
||||||
|
if len(settings) == 0 {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make(map[string]interface{}, len(settings))
|
||||||
|
for key, value := range settings {
|
||||||
|
if strings.HasPrefix(key, "_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
|
||||||
|
if ext.VM == nil || ext.runtime == nil {
|
||||||
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if applyStoredSettings && !ext.initialized {
|
||||||
|
settings := getExtensionInitSettings(ext.ID)
|
||||||
|
if len(settings) > 0 {
|
||||||
|
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext.initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.Error = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ext *loadedExtension) ensureRuntimeReady() error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
|
return ensureRuntimeReadyLocked(ext, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||||
|
ext.VMMu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ext.VM, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type extensionManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
extensions map[string]*LoadedExtension
|
extensions map[string]*loadedExtension
|
||||||
extensionsDir string
|
extensionsDir string
|
||||||
dataDir string
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalExtManager *ExtensionManager
|
globalExtManager *extensionManager
|
||||||
globalExtManagerOnce sync.Once
|
globalExtManagerOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetExtensionManager() *ExtensionManager {
|
func getExtensionManager() *extensionManager {
|
||||||
globalExtManagerOnce.Do(func() {
|
globalExtManagerOnce.Do(func() {
|
||||||
globalExtManager = &ExtensionManager{
|
globalExtManager = &extensionManager{
|
||||||
extensions: make(map[string]*LoadedExtension),
|
extensions: make(map[string]*loadedExtension),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalExtManager
|
return globalExtManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -93,7 +154,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -150,7 +211,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
if exists {
|
if exists {
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
// This is an upgrade - call UpgradeExtension
|
|
||||||
return m.UpgradeExtension(filePath)
|
return m.UpgradeExtension(filePath)
|
||||||
} else if versionCompare == 0 {
|
} else if versionCompare == 0 {
|
||||||
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||||
@@ -212,7 +272,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Enabled: false, // New extensions start disabled
|
Enabled: false, // New extensions start disabled
|
||||||
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
func initializeVMLocked(ext *loadedExtension) error {
|
||||||
|
ext.VM = nil
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.initialized = false
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
|
|
||||||
@@ -242,7 +305,8 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
ext.runtime = runtime
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
|
|
||||||
@@ -278,7 +342,137 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
return initializeVMLocked(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeExtensionWithSettingsLocked(
|
||||||
|
ext *loadedExtension,
|
||||||
|
settings map[string]interface{},
|
||||||
|
) error {
|
||||||
|
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", ext.ID, 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", ext.ID, errMsg)
|
||||||
|
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.initialized = true
|
||||||
|
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCleanupLocked(ext *loadedExtension) error {
|
||||||
|
if ext.VM != 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||||
|
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardownVMLocked(ext *loadedExtension) {
|
||||||
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
if ext.runtime != nil {
|
||||||
|
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||||
|
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
ext.runtime.closeStorageFlusher()
|
||||||
|
}
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.VM = nil
|
||||||
|
ext.initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExtensionLoad(ext *loadedExtension) error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -287,14 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM != nil {
|
ext.VMMu.Lock()
|
||||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
teardownVMLocked(ext)
|
||||||
if err != nil {
|
ext.VMMu.Unlock()
|
||||||
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)
|
delete(m.extensions, extensionID)
|
||||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||||
@@ -302,7 +491,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
@@ -313,18 +502,18 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
func (m *extensionManager) GetAllExtensions() []*loadedExtension {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
result := make([]*LoadedExtension, 0, len(m.extensions))
|
result := make([]*loadedExtension, 0, len(m.extensions))
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
result = append(result, ext)
|
result = append(result, ext)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -333,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.Enabled = enabled
|
if enabled {
|
||||||
|
ext.Enabled = true
|
||||||
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
|
store := GetExtensionSettingsStore()
|
||||||
|
ext.Enabled = false
|
||||||
|
_ = store.Set(extensionID, "_enabled", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext.Enabled = false
|
||||||
|
ext.Error = ""
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
ext.VMMu.Unlock()
|
||||||
|
}
|
||||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||||
|
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
@@ -344,7 +547,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||||
var loaded []string
|
var loaded []string
|
||||||
var errors []error
|
var errors []error
|
||||||
|
|
||||||
@@ -382,7 +585,7 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
return loaded, errors
|
return loaded, errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -392,7 +595,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
@@ -413,7 +615,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Enabled: false, // Will be restored from settings store
|
Enabled: false, // Will be restored from settings store
|
||||||
@@ -421,7 +623,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
SourceDir: dirPath,
|
SourceDir: dirPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore enabled state from settings store
|
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
||||||
if enabled, ok := enabledVal.(bool); ok {
|
if enabled, ok := enabledVal.(bool); ok {
|
||||||
@@ -430,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -442,7 +643,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||||
ext, err := m.GetExtension(extensionID)
|
ext, err := m.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -458,17 +659,11 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally remove data directory (keep for now to preserve settings)
|
|
||||||
// if ext.DataDir != "" {
|
|
||||||
// os.RemoveAll(ext.DataDir)
|
|
||||||
// }
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allows upgrades (new version > current version), not downgrades
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||||
// Validate file extension
|
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -520,7 +715,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
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)
|
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
||||||
if versionCompare < 0 {
|
if versionCompare < 0 {
|
||||||
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
||||||
@@ -531,12 +725,10 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
|
|
||||||
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
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
|
extDataDir := existing.DataDir
|
||||||
extDir := existing.SourceDir
|
extDir := existing.SourceDir
|
||||||
wasEnabled := existing.Enabled
|
wasEnabled := existing.Enabled
|
||||||
|
|
||||||
m.CleanupExtension(existing.ID)
|
|
||||||
m.UnloadExtension(existing.ID)
|
m.UnloadExtension(existing.ID)
|
||||||
|
|
||||||
if extDir != "" {
|
if extDir != "" {
|
||||||
@@ -585,7 +777,7 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: newManifest.Name,
|
ID: newManifest.Name,
|
||||||
Manifest: newManifest,
|
Manifest: newManifest,
|
||||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||||
@@ -593,11 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Goja VM
|
if wasEnabled {
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
|
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
|
||||||
|
}
|
||||||
|
} else if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -617,8 +812,7 @@ type ExtensionUpgradeInfo struct {
|
|||||||
IsInstalled bool `json:"is_installed"`
|
IsInstalled bool `json:"is_installed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
// Validate file extension
|
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -667,7 +861,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
// Not installed - this is a new install, not upgrade
|
|
||||||
info.CurrentVersion = ""
|
info.CurrentVersion = ""
|
||||||
info.CanUpgrade = false
|
info.CanUpgrade = false
|
||||||
} else {
|
} else {
|
||||||
@@ -678,7 +871,7 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -692,7 +885,7 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||||
extensions := m.GetAllExtensions()
|
extensions := m.GetAllExtensions()
|
||||||
|
|
||||||
type ExtensionInfo struct {
|
type ExtensionInfo struct {
|
||||||
@@ -715,6 +908,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
HasDownloadProvider bool `json:"has_download_provider"`
|
HasDownloadProvider bool `json:"has_download_provider"`
|
||||||
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||||
|
SkipLyrics bool `json:"skip_lyrics"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||||
@@ -731,7 +925,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
permissions = append(permissions, "storage:enabled")
|
permissions = append(permissions, "storage:enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status
|
|
||||||
status := "loaded"
|
status := "loaded"
|
||||||
if ext.Error != "" {
|
if ext.Error != "" {
|
||||||
status = "error"
|
status = "error"
|
||||||
@@ -773,6 +966,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||||
|
SkipLyrics: ext.Manifest.SkipLyrics,
|
||||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||||
TrackMatching: ext.Manifest.TrackMatching,
|
TrackMatching: ext.Manifest.TrackMatching,
|
||||||
PostProcessing: ext.Manifest.PostProcessing,
|
PostProcessing: ext.Manifest.PostProcessing,
|
||||||
@@ -788,7 +982,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -797,59 +991,16 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
ext.VMMu.Lock()
|
||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
defer ext.VMMu.Unlock()
|
||||||
}
|
|
||||||
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||||
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 {
|
func (m *extensionManager) CleanupExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -861,46 +1012,17 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ext.VMMu.Lock()
|
||||||
script := `
|
defer ext.VMMu.Unlock()
|
||||||
(function() {
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
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)
|
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||||
return 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)
|
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
func (m *extensionManager) UnloadAllExtensions() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
extensionIDs := make([]string, 0, len(m.extensions))
|
extensionIDs := make([]string, 0, len(m.extensions))
|
||||||
for id := range m.extensions {
|
for id := range m.extensions {
|
||||||
@@ -909,14 +1031,13 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
for _, id := range extensionIDs {
|
for _, id := range extensionIDs {
|
||||||
m.CleanupExtension(id)
|
|
||||||
m.UnloadExtension(id)
|
m.UnloadExtension(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Extension] All extensions unloaded\n")
|
GoLog("[Extension] All extensions unloaded\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -925,15 +1046,15 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
|
||||||
return nil, fmt.Errorf("extension VM not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ext.Enabled {
|
if !ext.Enabled {
|
||||||
return nil, fmt.Errorf("extension is disabled")
|
return nil, fmt.Errorf("extension is disabled")
|
||||||
}
|
}
|
||||||
|
vm, err := ext.lockReadyVM()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
// Call the action function on the extension object
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
@@ -952,7 +1073,7 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
})()
|
})()
|
||||||
`, actionName, actionName, actionName)
|
`, actionName, actionName, actionName)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||||
return nil, fmt.Errorf("action failed: %v", err)
|
return nil, fmt.Errorf("action failed: %v", err)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides extension manifest parsing and validation
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -116,6 +115,7 @@ type ExtensionManifest struct {
|
|||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
|
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||||
|
original := GetMetadataProviderPriority()
|
||||||
|
defer SetMetadataProviderPriority(original)
|
||||||
|
|
||||||
|
SetMetadataProviderPriority([]string{"tidal"})
|
||||||
|
got := GetMetadataProviderPriority()
|
||||||
|
want := []string{"tidal", "deezer", "qobuz"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
|
||||||
|
|
||||||
|
got := GetExtensionFallbackProviderIDs()
|
||||||
|
want := []string{"ext-a", "ext-b"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs(nil)
|
||||||
|
|
||||||
|
if !isExtensionFallbackAllowed("custom-ext") {
|
||||||
|
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
||||||
|
}
|
||||||
|
if !isExtensionFallbackAllowed("qobuz") {
|
||||||
|
t.Fatal("expected built-in provider to remain allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
||||||
|
original := GetExtensionFallbackProviderIDs()
|
||||||
|
defer SetExtensionFallbackProviderIDs(original)
|
||||||
|
|
||||||
|
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
|
||||||
|
|
||||||
|
if !isExtensionFallbackAllowed("allowed-ext") {
|
||||||
|
t.Fatal("expected explicitly allowed extension to be permitted")
|
||||||
|
}
|
||||||
|
if isExtensionFallbackAllowed("blocked-ext") {
|
||||||
|
t.Fatal("expected extension outside allowlist to be blocked")
|
||||||
|
}
|
||||||
|
if isExtensionFallbackAllowed("deezer") {
|
||||||
|
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||||
|
original := GetProviderPriority()
|
||||||
|
defer SetProviderPriority(original)
|
||||||
|
|
||||||
|
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||||
|
|
||||||
|
got := GetProviderPriority()
|
||||||
|
want := []string{"qobuz", "custom-ext", "tidal"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||||
|
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||||
|
if normalized == nil {
|
||||||
|
t.Fatal("expected legacy decryption key to produce normalized descriptor")
|
||||||
|
}
|
||||||
|
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||||
|
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||||
|
}
|
||||||
|
if normalized.Key != "001122" {
|
||||||
|
t.Fatalf("key = %q", normalized.Key)
|
||||||
|
}
|
||||||
|
if normalized.InputFormat != "mov" {
|
||||||
|
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||||
|
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
|
||||||
|
Strategy: "mp4_decryption_key",
|
||||||
|
Key: "abcd",
|
||||||
|
InputFormat: "",
|
||||||
|
}, "")
|
||||||
|
if normalized == nil {
|
||||||
|
t.Fatal("expected descriptor to remain available")
|
||||||
|
}
|
||||||
|
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||||
|
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||||
|
}
|
||||||
|
if normalized.InputFormat != "mov" {
|
||||||
|
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
outputPath := buildOutputPath(DownloadRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputDir: outputDir,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
FilenameFormat: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !isPathInAllowedDirs(outputPath) {
|
||||||
|
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
outputPath := filepath.Join(outputDir, "custom.flac")
|
||||||
|
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||||
|
|
||||||
|
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||||
|
OutputPath: outputPath,
|
||||||
|
}, ext)
|
||||||
|
|
||||||
|
if resolved != outputPath {
|
||||||
|
t.Fatalf("resolved output path = %q", resolved)
|
||||||
|
}
|
||||||
|
if !isPathInAllowedDirs(outputPath) {
|
||||||
|
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||||
|
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||||
|
TrackName: "Song",
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputDir: filepath.Join("Artist", "Album"),
|
||||||
|
OutputFD: 123,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
}, ext)
|
||||||
|
|
||||||
|
expectedBase := filepath.Join(ext.DataDir, "downloads")
|
||||||
|
if !isPathWithinBase(expectedBase, resolved) {
|
||||||
|
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
|
||||||
|
}
|
||||||
|
if !isPathInAllowedDirs(resolved) {
|
||||||
|
t.Fatalf("expected resolved output path %q to be allowed", resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||||
|
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||||
|
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if canEmbedGenreLabel("relative.flac") {
|
||||||
|
t.Fatal("expected relative path to be rejected")
|
||||||
|
}
|
||||||
|
if canEmbedGenreLabel("content://example") {
|
||||||
|
t.Fatal("expected content URI to be rejected")
|
||||||
|
}
|
||||||
|
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
||||||
|
t.Fatal("expected missing file to be rejected")
|
||||||
|
}
|
||||||
|
if !canEmbedGenreLabel(tempFile) {
|
||||||
|
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||||
|
originalPriority := GetMetadataProviderPriority()
|
||||||
|
originalSearch := searchBuiltInMetadataTracksFunc
|
||||||
|
defer func() {
|
||||||
|
SetMetadataProviderPriority(originalPriority)
|
||||||
|
searchBuiltInMetadataTracksFunc = originalSearch
|
||||||
|
}()
|
||||||
|
|
||||||
|
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
||||||
|
|
||||||
|
var calls []string
|
||||||
|
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||||
|
calls = append(calls, providerID)
|
||||||
|
switch providerID {
|
||||||
|
case "qobuz":
|
||||||
|
return []ExtTrackMetadata{
|
||||||
|
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
|
||||||
|
}, nil
|
||||||
|
case "tidal":
|
||||||
|
return []ExtTrackMetadata{
|
||||||
|
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
||||||
|
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
||||||
|
}, nil
|
||||||
|
case "deezer":
|
||||||
|
return []ExtTrackMetadata{
|
||||||
|
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := getExtensionManager()
|
||||||
|
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tracks) != 3 {
|
||||||
|
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
|
||||||
|
}
|
||||||
|
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
|
||||||
|
t.Fatalf("unexpected track provider order: %+v", tracks)
|
||||||
|
}
|
||||||
|
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
||||||
|
t.Fatalf("unexpected provider call order: %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,61 +80,123 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
|||||||
state.IsAuthenticated = accessToken != ""
|
state.IsAuthenticated = accessToken != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionRuntime struct {
|
type extensionRuntime struct {
|
||||||
extensionID string
|
extensionID string
|
||||||
manifest *ExtensionManifest
|
manifest *ExtensionManifest
|
||||||
settings map[string]interface{}
|
settings map[string]interface{}
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
cookieJar http.CookieJar
|
downloadClient *http.Client
|
||||||
dataDir string
|
cookieJar http.CookieJar
|
||||||
vm *goja.Runtime
|
dataDir string
|
||||||
|
vm *goja.Runtime
|
||||||
|
|
||||||
|
activeDownloadMu sync.RWMutex
|
||||||
|
activeDownloadItemID string
|
||||||
|
|
||||||
|
storageMu sync.RWMutex
|
||||||
|
storageCache map[string]interface{}
|
||||||
|
storageLoaded bool
|
||||||
|
storageDirty bool
|
||||||
|
storageClosed bool
|
||||||
|
storageTimer *time.Timer
|
||||||
|
storageWriteMu sync.Mutex
|
||||||
|
|
||||||
|
credentialsMu sync.RWMutex
|
||||||
|
credentialsCache map[string]interface{}
|
||||||
|
credentialsLoaded bool
|
||||||
|
storageFlushDelay time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
type privateIPCacheEntry struct {
|
||||||
|
isPrivate bool
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
privateIPCacheTTL = 5 * time.Minute
|
||||||
|
privateIPErrorCacheTTL = 30 * time.Second
|
||||||
|
maxPrivateIPCacheSize = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
privateIPCache = make(map[string]privateIPCacheEntry)
|
||||||
|
privateIPCacheMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||||
jar, _ := newSimpleCookieJar()
|
jar, _ := newSimpleCookieJar()
|
||||||
|
|
||||||
runtime := &ExtensionRuntime{
|
runtime := &extensionRuntime{
|
||||||
extensionID: ext.ID,
|
extensionID: ext.ID,
|
||||||
manifest: ext.Manifest,
|
manifest: ext.Manifest,
|
||||||
settings: make(map[string]interface{}),
|
settings: make(map[string]interface{}),
|
||||||
cookieJar: jar,
|
cookieJar: jar,
|
||||||
dataDir: ext.DataDir,
|
dataDir: ext.DataDir,
|
||||||
vm: ext.VM,
|
vm: ext.VM,
|
||||||
|
storageFlushDelay: defaultStorageFlushDelay,
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||||
Timeout: 30 * time.Second,
|
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||||
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
|
return runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||||
|
r.activeDownloadMu.Lock()
|
||||||
|
defer r.activeDownloadMu.Unlock()
|
||||||
|
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) clearActiveDownloadItemID() {
|
||||||
|
r.activeDownloadMu.Lock()
|
||||||
|
defer r.activeDownloadMu.Unlock()
|
||||||
|
r.activeDownloadItemID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||||
|
r.activeDownloadMu.RLock()
|
||||||
|
defer r.activeDownloadMu.RUnlock()
|
||||||
|
return r.activeDownloadItemID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||||
|
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||||
|
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||||
|
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||||
|
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: sharedTransport,
|
||||||
|
Timeout: timeout,
|
||||||
|
Jar: jar,
|
||||||
|
}
|
||||||
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
if req.URL.Scheme != "https" {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||||
|
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := req.URL.Hostname()
|
||||||
|
if domain == "" {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
|
||||||
|
return fmt.Errorf("redirect blocked: hostname is required")
|
||||||
|
}
|
||||||
|
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||||
|
return &RedirectBlockedError{Domain: domain}
|
||||||
|
}
|
||||||
|
if isPrivateIP(domain) {
|
||||||
|
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||||
|
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||||
|
}
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
type RedirectBlockedError struct {
|
type RedirectBlockedError struct {
|
||||||
Domain string
|
Domain string
|
||||||
IsPrivate bool
|
IsPrivate bool
|
||||||
@@ -147,7 +209,6 @@ func (e *RedirectBlockedError) Error() string {
|
|||||||
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
|
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 {
|
func isPrivateIP(host string) bool {
|
||||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||||
if hostLower == "" {
|
if hostLower == "" {
|
||||||
@@ -162,18 +223,68 @@ func isPrivateIP(host string) bool {
|
|||||||
return isPrivateIPAddr(ip)
|
return isPrivateIPAddr(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cached, ok := getPrivateIPCache(hostLower); ok {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
ips, err := net.LookupIP(hostLower)
|
ips, err := net.LookupIP(hostLower)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
setPrivateIPCache(hostLower, false, privateIPErrorCacheTTL)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPrivate := false
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
if isPrivateIPAddr(ip) {
|
if isPrivateIPAddr(ip) {
|
||||||
return true
|
isPrivate = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL)
|
||||||
|
return isPrivate
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPrivateIPCache(host string) (bool, bool) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
privateIPCacheMu.RLock()
|
||||||
|
entry, exists := privateIPCache[host]
|
||||||
|
privateIPCacheMu.RUnlock()
|
||||||
|
if !exists {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Before(entry.expiresAt) {
|
||||||
|
return entry.isPrivate, true
|
||||||
|
}
|
||||||
|
|
||||||
|
privateIPCacheMu.Lock()
|
||||||
|
delete(privateIPCache, host)
|
||||||
|
privateIPCacheMu.Unlock()
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPrivateIPCache(host string, isPrivate bool, ttl time.Duration) {
|
||||||
|
expiresAt := time.Now().Add(ttl)
|
||||||
|
|
||||||
|
privateIPCacheMu.Lock()
|
||||||
|
if len(privateIPCache) >= maxPrivateIPCacheSize {
|
||||||
|
now := time.Now()
|
||||||
|
for key, entry := range privateIPCache {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(privateIPCache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(privateIPCache) >= maxPrivateIPCacheSize {
|
||||||
|
privateIPCache = make(map[string]privateIPCacheEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
privateIPCache[host] = privateIPCacheEntry{
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
privateIPCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPrivateIPAddr(ip net.IP) bool {
|
func isPrivateIPAddr(ip net.IP) bool {
|
||||||
@@ -218,11 +329,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
|||||||
return j.cookies[u.Host]
|
return j.cookies[u.Host]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||||
r.settings = settings
|
r.settings = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||||
r.vm = vm
|
r.vm = vm
|
||||||
|
|
||||||
httpObj := vm.NewObject()
|
httpObj := vm.NewObject()
|
||||||
@@ -266,7 +377,9 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
fileObj.Set("exists", r.fileExists)
|
fileObj.Set("exists", r.fileExists)
|
||||||
fileObj.Set("delete", r.fileDelete)
|
fileObj.Set("delete", r.fileDelete)
|
||||||
fileObj.Set("read", r.fileRead)
|
fileObj.Set("read", r.fileRead)
|
||||||
|
fileObj.Set("readBytes", r.fileReadBytes)
|
||||||
fileObj.Set("write", r.fileWrite)
|
fileObj.Set("write", r.fileWrite)
|
||||||
|
fileObj.Set("writeBytes", r.fileWriteBytes)
|
||||||
fileObj.Set("copy", r.fileCopy)
|
fileObj.Set("copy", r.fileCopy)
|
||||||
fileObj.Set("move", r.fileMove)
|
fileObj.Set("move", r.fileMove)
|
||||||
fileObj.Set("getSize", r.fileGetSize)
|
fileObj.Set("getSize", r.fileGetSize)
|
||||||
@@ -296,6 +409,8 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
|
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||||
|
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||||
vm.Set("utils", utilsObj)
|
vm.Set("utils", utilsObj)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Auth API and PKCE support for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,8 +15,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Auth API (OAuth Support) ====================
|
|
||||||
|
|
||||||
func validateExtensionAuthURL(urlStr string) error {
|
func validateExtensionAuthURL(urlStr string) error {
|
||||||
parsed, err := url.Parse(urlStr)
|
parsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
|
|||||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -102,7 +99,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -114,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(state.AuthCode)
|
return r.vm.ToValue(state.AuthCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -152,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
delete(extensionAuthState, r.extensionID)
|
delete(extensionAuthState, r.extensionID)
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
@@ -165,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -181,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
|||||||
return r.vm.ToValue(state.IsAuthenticated)
|
return r.vm.ToValue(state.IsAuthenticated)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -204,10 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
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) {
|
func generatePKCEVerifier(length int) (string, error) {
|
||||||
if length < 43 {
|
if length < 43 {
|
||||||
length = 43
|
length = 43
|
||||||
@@ -232,11 +225,10 @@ func generatePKCEVerifier(length int) (string, error) {
|
|||||||
|
|
||||||
func generatePKCEChallenge(verifier string) string {
|
func generatePKCEChallenge(verifier string) string {
|
||||||
hash := sha256.Sum256([]byte(verifier))
|
hash := sha256.Sum256([]byte(verifier))
|
||||||
// Base64url encode without padding (RFC 7636)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||||
length := 64
|
length := 64
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||||
@@ -273,7 +265,7 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -289,8 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -394,10 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
|
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
// 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 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -414,7 +402,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required fields
|
|
||||||
tokenURL, _ := config["tokenUrl"].(string)
|
tokenURL, _ := config["tokenUrl"].(string)
|
||||||
clientID, _ := config["clientId"].(string)
|
clientID, _ := config["clientId"].(string)
|
||||||
redirectURI, _ := config["redirectUri"].(string)
|
redirectURI, _ := config["redirectUri"].(string)
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
"golang.org/x/crypto/blowfish"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runtimeBlockCipherOptions struct {
|
||||||
|
Algorithm string
|
||||||
|
Mode string
|
||||||
|
Key []byte
|
||||||
|
IV []byte
|
||||||
|
InputEncoding string
|
||||||
|
OutputEncoding string
|
||||||
|
Padding string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
|
||||||
|
if len(call.Arguments) <= index {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value := call.Arguments[index]
|
||||||
|
if goja.IsUndefined(value) || goja.IsNull(value) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := value.Export()
|
||||||
|
if options, ok := exported.(map[string]interface{}); ok {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
|
||||||
|
if options == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
raw, ok := options[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
case []byte:
|
||||||
|
if len(value) > 0 {
|
||||||
|
return string(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
|
||||||
|
if options == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
raw, ok := options[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case bool:
|
||||||
|
return value
|
||||||
|
case int:
|
||||||
|
return value != 0
|
||||||
|
case int64:
|
||||||
|
return value != 0
|
||||||
|
case float64:
|
||||||
|
return value != 0
|
||||||
|
case string:
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
|
||||||
|
if options == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
raw, ok := options[key]
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case int:
|
||||||
|
return int64(value)
|
||||||
|
case int32:
|
||||||
|
return int64(value)
|
||||||
|
case int64:
|
||||||
|
return value
|
||||||
|
case float32:
|
||||||
|
return int64(value)
|
||||||
|
case float64:
|
||||||
|
return int64(value)
|
||||||
|
case string:
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
var parsed int64
|
||||||
|
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
|
||||||
|
if options == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, exists := options[key]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
|
case "", "utf8", "utf-8", "text":
|
||||||
|
return []byte(input), nil
|
||||||
|
case "base64":
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid base64 data: %w", err)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
case "hex":
|
||||||
|
decoded, err := hex.DecodeString(strings.TrimSpace(input))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid hex data: %w", err)
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
return decodeRuntimeBytesString(value, encoding)
|
||||||
|
case []byte:
|
||||||
|
cloned := make([]byte, len(value))
|
||||||
|
copy(cloned, value)
|
||||||
|
return cloned, nil
|
||||||
|
case []interface{}:
|
||||||
|
decoded := make([]byte, len(value))
|
||||||
|
for i, item := range value {
|
||||||
|
switch num := item.(type) {
|
||||||
|
case int:
|
||||||
|
decoded[i] = byte(num)
|
||||||
|
case int64:
|
||||||
|
decoded[i] = byte(num)
|
||||||
|
case float64:
|
||||||
|
decoded[i] = byte(int(num))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported byte payload type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||||
|
case "", "base64":
|
||||||
|
return base64.StdEncoding.EncodeToString(data), nil
|
||||||
|
case "hex":
|
||||||
|
return hex.EncodeToString(data), nil
|
||||||
|
case "utf8", "utf-8", "text":
|
||||||
|
return string(data), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
|
||||||
|
parsed := &runtimeBlockCipherOptions{
|
||||||
|
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
|
||||||
|
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
|
||||||
|
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
|
||||||
|
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
|
||||||
|
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
|
||||||
|
}
|
||||||
|
if parsed.Algorithm == "" {
|
||||||
|
return nil, fmt.Errorf("algorithm is required")
|
||||||
|
}
|
||||||
|
if parsed.Mode == "" {
|
||||||
|
return nil, fmt.Errorf("mode is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid key: %w", err)
|
||||||
|
}
|
||||||
|
if len(key) == 0 {
|
||||||
|
return nil, fmt.Errorf("key is required")
|
||||||
|
}
|
||||||
|
parsed.Key = key
|
||||||
|
|
||||||
|
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid iv: %w", err)
|
||||||
|
}
|
||||||
|
parsed.IV = iv
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
|
||||||
|
switch options.Algorithm {
|
||||||
|
case "blowfish":
|
||||||
|
return blowfish.NewCipher(options.Key)
|
||||||
|
case "aes":
|
||||||
|
return aes.NewCipher(options.Key)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPKCS7Padding(data []byte, blockSize int) []byte {
|
||||||
|
padding := blockSize - (len(data) % blockSize)
|
||||||
|
if padding == 0 {
|
||||||
|
padding = blockSize
|
||||||
|
}
|
||||||
|
out := make([]byte, len(data)+padding)
|
||||||
|
copy(out, data)
|
||||||
|
for i := len(data); i < len(out); i++ {
|
||||||
|
out[i] = byte(padding)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
|
||||||
|
if len(data) == 0 || len(data)%blockSize != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid padded payload length")
|
||||||
|
}
|
||||||
|
padding := int(data[len(data)-1])
|
||||||
|
if padding <= 0 || padding > blockSize || padding > len(data) {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
for i := len(data) - padding; i < len(data); i++ {
|
||||||
|
if int(data[i]) != padding {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:len(data)-padding], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "data and options are required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 1)
|
||||||
|
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if parsedOptions.Mode != "cbc" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := newRuntimeBlockCipher(parsedOptions)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parsedOptions.IV) != block.BlockSize() {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := inputData
|
||||||
|
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
||||||
|
data = applyPKCS7Padding(data, block.BlockSize())
|
||||||
|
}
|
||||||
|
if len(data)%block.BlockSize() != 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
output := make([]byte, len(data))
|
||||||
|
if decrypt {
|
||||||
|
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
if parsedOptions.Padding == "pkcs7" {
|
||||||
|
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||||
|
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": encoded,
|
||||||
|
"block_size": block.BlockSize(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.transformBlockCipher(call, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||||
|
return r.transformBlockCipher(call, true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "binary-test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "binary-test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
File: withFilePermission,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := newExtensionRuntime(ext)
|
||||||
|
vm := goja.New()
|
||||||
|
runtime.RegisterAPIs(vm)
|
||||||
|
return vm
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var decoded T
|
||||||
|
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
|
||||||
|
t.Fatalf("failed to decode JSON result: %v", err)
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, true)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
|
||||||
|
if (!first.success) throw new Error(first.error);
|
||||||
|
|
||||||
|
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
|
||||||
|
if (!second.success) throw new Error(second.error);
|
||||||
|
|
||||||
|
var all = file.readBytes("bytes.bin", {encoding: "hex"});
|
||||||
|
if (!all.success) throw new Error(all.error);
|
||||||
|
|
||||||
|
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
|
||||||
|
if (!slice.success) throw new Error(slice.error);
|
||||||
|
|
||||||
|
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
|
||||||
|
if (!tail.success) throw new Error(tail.error);
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
all: all.data,
|
||||||
|
slice: slice.data,
|
||||||
|
size: all.size,
|
||||||
|
sliceBytes: slice.bytes_read,
|
||||||
|
sliceEof: slice.eof,
|
||||||
|
tailBytes: tail.bytes_read,
|
||||||
|
tailEof: tail.eof
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("file byte APIs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
All string `json:"all"`
|
||||||
|
Slice string `json:"slice"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SliceBytes int `json:"sliceBytes"`
|
||||||
|
SliceEof bool `json:"sliceEof"`
|
||||||
|
TailBytes int `json:"tailBytes"`
|
||||||
|
TailEof bool `json:"tailEof"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.All != "0001020304ff" {
|
||||||
|
t.Fatalf("all = %q", decoded.All)
|
||||||
|
}
|
||||||
|
if decoded.Slice != "0203" {
|
||||||
|
t.Fatalf("slice = %q", decoded.Slice)
|
||||||
|
}
|
||||||
|
if decoded.Size != 6 {
|
||||||
|
t.Fatalf("size = %d", decoded.Size)
|
||||||
|
}
|
||||||
|
if decoded.SliceBytes != 2 {
|
||||||
|
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
|
||||||
|
}
|
||||||
|
if decoded.SliceEof {
|
||||||
|
t.Fatal("slice should not be EOF")
|
||||||
|
}
|
||||||
|
if decoded.TailBytes != 0 || !decoded.TailEof {
|
||||||
|
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "blowfish",
|
||||||
|
mode: "cbc",
|
||||||
|
key: "0123456789ABCDEFF0E1D2C3B4A59687",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0001020304050607",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "hex",
|
||||||
|
outputEncoding: "hex",
|
||||||
|
padding: "none"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("blowfish block cipher failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Enc string `json:"enc"`
|
||||||
|
Dec string `json:"dec"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Dec != "00112233445566778899aabbccddeeff" {
|
||||||
|
t.Fatalf("dec = %q", decoded.Dec)
|
||||||
|
}
|
||||||
|
if decoded.Enc == decoded.Dec {
|
||||||
|
t.Fatal("expected ciphertext to differ from plaintext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "cbc",
|
||||||
|
key: "000102030405060708090a0b0c0d0e0f",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "utf8",
|
||||||
|
outputEncoding: "base64",
|
||||||
|
padding: "pkcs7"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("hello generic cbc", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "cbc",
|
||||||
|
key: options.key,
|
||||||
|
keyEncoding: options.keyEncoding,
|
||||||
|
iv: options.iv,
|
||||||
|
ivEncoding: options.ivEncoding,
|
||||||
|
inputEncoding: "base64",
|
||||||
|
outputEncoding: "utf8",
|
||||||
|
padding: "pkcs7"
|
||||||
|
});
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return dec.data;
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes block cipher failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.String() != "hello generic cbc" {
|
||||||
|
t.Fatalf("unexpected decrypted value: %q", result.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides FFmpeg API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,9 +9,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== FFmpeg API (Post-Processing) ====================
|
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute.
|
||||||
|
|
||||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
|
||||||
type FFmpegCommand struct {
|
type FFmpegCommand struct {
|
||||||
ExtensionID string
|
ExtensionID string
|
||||||
Command string
|
Command string
|
||||||
@@ -24,7 +21,6 @@ type FFmpegCommand struct {
|
|||||||
Output string
|
Output string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global FFmpeg command queue
|
|
||||||
var (
|
var (
|
||||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||||
ffmpegCommandsMu sync.RWMutex
|
ffmpegCommandsMu sync.RWMutex
|
||||||
@@ -54,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
|
|||||||
delete(ffmpegCommands, commandID)
|
delete(ffmpegCommands, commandID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -111,7 +107,7 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -138,7 +134,7 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides File API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,8 +12,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== File API (Sandboxed) ====================
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
allowedDownloadDirs []string
|
allowedDownloadDirs []string
|
||||||
allowedDownloadDirsMu sync.RWMutex
|
allowedDownloadDirsMu sync.RWMutex
|
||||||
@@ -74,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
func (r *extensionRuntime) validatePath(path string) (string, error) {
|
||||||
if !r.manifest.Permissions.File {
|
if !r.manifest.Permissions.File {
|
||||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||||
}
|
}
|
||||||
@@ -109,7 +106,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
|||||||
return absPath, nil
|
return absPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -177,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := r.httpClient.Do(req)
|
client := r.downloadClient
|
||||||
|
if client == nil {
|
||||||
|
client = r.httpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -203,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
contentLength := resp.ContentLength
|
contentLength := resp.ContentLength
|
||||||
|
activeItemID := r.getActiveDownloadItemID()
|
||||||
|
if activeItemID != "" && contentLength > 0 {
|
||||||
|
SetItemBytesTotal(activeItemID, contentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||||
|
if activeItemID != "" {
|
||||||
|
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||||
|
}
|
||||||
|
|
||||||
var written int64
|
var written int64
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
for {
|
for {
|
||||||
nr, er := resp.Body.Read(buf)
|
nr, er := resp.Body.Read(buf)
|
||||||
if nr > 0 {
|
if nr > 0 {
|
||||||
nw, ew := out.Write(buf[0:nr])
|
nw, ew := progressWriter.Write(buf[0:nr])
|
||||||
if nw < 0 || nr < nw {
|
if nw < 0 || nr < nw {
|
||||||
nw = 0
|
nw = 0
|
||||||
if ew == nil {
|
if ew == nil {
|
||||||
@@ -218,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
written += int64(nw)
|
written += int64(nw)
|
||||||
if ew != nil {
|
if ew != nil {
|
||||||
|
if ew == ErrDownloadCancelled {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "download cancelled",
|
||||||
|
})
|
||||||
|
}
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||||
@@ -254,7 +271,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -269,7 +286,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(err == nil)
|
return r.vm.ToValue(err == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -298,7 +315,7 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -329,7 +346,105 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileReadBytes(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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 1)
|
||||||
|
offset := runtimeOptionInt64(options, "offset", 0)
|
||||||
|
length := runtimeOptionInt64(options, "length", -1)
|
||||||
|
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||||
|
if offset < 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "offset must be >= 0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
size := info.Size()
|
||||||
|
if offset > size {
|
||||||
|
offset = size
|
||||||
|
}
|
||||||
|
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var data []byte
|
||||||
|
switch {
|
||||||
|
case length == 0:
|
||||||
|
data = []byte{}
|
||||||
|
case length > 0:
|
||||||
|
buf := make([]byte, int(length))
|
||||||
|
n, readErr := file.Read(buf)
|
||||||
|
if readErr != nil && readErr != io.EOF {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read file: %v", readErr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data = buf[:n]
|
||||||
|
default:
|
||||||
|
data, err = io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to read file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||||
|
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": encoded,
|
||||||
|
"bytes_read": len(data),
|
||||||
|
"offset": offset,
|
||||||
|
"size": size,
|
||||||
|
"eof": offset+int64(len(data)) >= size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -369,7 +484,108 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileWriteBytes(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()
|
||||||
|
fullPath, err := r.validatePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 2)
|
||||||
|
appendMode := runtimeOptionBool(options, "append", false)
|
||||||
|
truncate := runtimeOptionBool(options, "truncate", false)
|
||||||
|
hasOffset := runtimeOptionHasKey(options, "offset")
|
||||||
|
offset := runtimeOptionInt64(options, "offset", 0)
|
||||||
|
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||||
|
|
||||||
|
if appendMode && hasOffset {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "append and offset cannot be used together",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "offset must be >= 0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := os.O_CREATE | os.O_WRONLY
|
||||||
|
if appendMode {
|
||||||
|
flags |= os.O_APPEND
|
||||||
|
}
|
||||||
|
if truncate {
|
||||||
|
flags |= os.O_TRUNC
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(fullPath, flags, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if hasOffset && !appendMode {
|
||||||
|
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
written, err := file.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
info, statErr := file.Stat()
|
||||||
|
size := int64(0)
|
||||||
|
if statErr == nil {
|
||||||
|
size = info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"path": fullPath,
|
||||||
|
"bytes_written": written,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -396,13 +612,14 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(fullSrc)
|
srcFile, err := os.Open(fullSrc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("failed to read source: %v", err),
|
"error": fmt.Sprintf("failed to read source: %v", err),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
defer srcFile.Close()
|
||||||
|
|
||||||
dir := filepath.Dir(fullDst)
|
dir := filepath.Dir(fullDst)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
@@ -412,10 +629,26 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
dstFile, err := os.OpenFile(fullDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||||
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("failed to write destination: %v", err),
|
"error": fmt.Sprintf("failed to open destination: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||||
|
_ = dstFile.Close()
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to copy file: %v", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dstFile.Close(); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("failed to finalize destination: %v", err),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +658,7 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -473,7 +706,7 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides HTTP API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,15 +11,13 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== HTTP API (Sandboxed) ====================
|
|
||||||
|
|
||||||
type HTTPResponse struct {
|
type HTTPResponse struct {
|
||||||
StatusCode int `json:"statusCode"`
|
StatusCode int `json:"statusCode"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||||
parsed, err := url.Parse(urlStr)
|
parsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid URL: %w", err)
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
@@ -52,7 +49,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -121,12 +118,13 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -217,12 +215,13 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -325,24 +324,25 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PUT", call)
|
return r.httpMethodShortcut("PUT", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("DELETE", call)
|
return r.httpMethodShortcut("DELETE", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PATCH", call)
|
return r.httpMethodShortcut("PATCH", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -449,12 +449,13 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode,
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||||
jar.mu.Lock()
|
jar.mu.Lock()
|
||||||
jar.cookies = make(map[string][]*http.Cookie)
|
jar.cookies = make(map[string][]*http.Cookie)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Track Matching API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,9 +6,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Track Matching API ====================
|
func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(0.0)
|
return r.vm.ToValue(0.0)
|
||||||
}
|
}
|
||||||
@@ -25,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
|||||||
return r.vm.ToValue(similarity)
|
return r.vm.ToValue(similarity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -46,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(diff <= tolerance)
|
return r.vm.ToValue(diff <= tolerance)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Browser-like Polyfills for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,13 +12,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Browser-like Polyfills ====================
|
func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
// 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 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.createFetchError("URL is required")
|
return r.createFetchError("URL is required")
|
||||||
}
|
}
|
||||||
@@ -41,7 +34,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
method = strings.ToUpper(m)
|
method = strings.ToUpper(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body - support string, object (auto-stringify), or nil
|
|
||||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||||
switch v := bodyArg.(type) {
|
switch v := bodyArg.(type) {
|
||||||
case string:
|
case string:
|
||||||
@@ -113,7 +105,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
responseObj.Set("status", resp.StatusCode)
|
responseObj.Set("status", resp.StatusCode)
|
||||||
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||||
responseObj.Set("headers", respHeaders)
|
responseObj.Set("headers", respHeaders)
|
||||||
responseObj.Set("url", urlStr)
|
responseObj.Set("url", resp.Request.URL.String())
|
||||||
|
|
||||||
bodyString := string(body)
|
bodyString := string(body)
|
||||||
|
|
||||||
@@ -141,8 +133,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return responseObj
|
return responseObj
|
||||||
}
|
}
|
||||||
|
|
||||||
// createFetchError creates a fetch error response
|
func (r *extensionRuntime) createFetchError(message string) goja.Value {
|
||||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
|
||||||
errorObj := r.vm.NewObject()
|
errorObj := r.vm.NewObject()
|
||||||
errorObj.Set("ok", false)
|
errorObj.Set("ok", false)
|
||||||
errorObj.Set("status", 0)
|
errorObj.Set("status", 0)
|
||||||
@@ -157,8 +148,7 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
|||||||
return errorObj
|
return errorObj
|
||||||
}
|
}
|
||||||
|
|
||||||
// atobPolyfill implements browser atob() - decode base64 to string
|
func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -174,8 +164,7 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
// btoaPolyfill implements browser btoa() - encode string to base64
|
func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -183,8 +172,7 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|
||||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
encoder := call.This
|
encoder := call.This
|
||||||
encoder.Set("encoding", "utf-8")
|
encoder.Set("encoding", "utf-8")
|
||||||
@@ -204,7 +192,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||||
// Simplified implementation
|
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||||
}
|
}
|
||||||
@@ -265,7 +252,7 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||||
urlObj := call.This
|
urlObj := call.This
|
||||||
|
|
||||||
@@ -429,9 +416,7 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerJSONGlobal ensures JSON global is properly set up
|
func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
|
||||||
// JSON is already built-in to Goja, but we can enhance it
|
|
||||||
jsonScript := `
|
jsonScript := `
|
||||||
if (typeof JSON === 'undefined') {
|
if (typeof JSON === 'undefined') {
|
||||||
var JSON = {
|
var JSON = {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Storage and Credentials API for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,58 +10,179 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Storage API ====================
|
const (
|
||||||
|
defaultStorageFlushDelay = 400 * time.Millisecond
|
||||||
|
storageFlushRetryDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getStoragePath() string {
|
func (r *extensionRuntime) getStoragePath() string {
|
||||||
return filepath.Join(r.dataDir, "storage.json")
|
return filepath.Join(r.dataDir, "storage.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return make(map[string]interface{})
|
||||||
|
}
|
||||||
|
dst := make(map[string]interface{}, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) ensureStorageLoaded() error {
|
||||||
|
r.storageMu.RLock()
|
||||||
|
if r.storageLoaded {
|
||||||
|
r.storageMu.RUnlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.storageMu.RUnlock()
|
||||||
|
|
||||||
|
r.storageMu.Lock()
|
||||||
|
defer r.storageMu.Unlock()
|
||||||
|
if r.storageLoaded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
storagePath := r.getStoragePath()
|
storagePath := r.getStoragePath()
|
||||||
data, err := os.ReadFile(storagePath)
|
data, err := os.ReadFile(storagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return make(map[string]interface{}), nil
|
r.storageCache = make(map[string]interface{})
|
||||||
|
r.storageLoaded = true
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var storage map[string]interface{}
|
var storage map[string]interface{}
|
||||||
if err := json.Unmarshal(data, &storage); err != nil {
|
if err := json.Unmarshal(data, &storage); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if storage == nil {
|
||||||
|
storage = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
r.storageCache = storage
|
||||||
|
r.storageLoaded = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||||
|
if err := r.ensureStorageLoaded(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return storage, nil
|
r.storageMu.RLock()
|
||||||
|
defer r.storageMu.RUnlock()
|
||||||
|
return cloneInterfaceMap(r.storageCache), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||||
storagePath := r.getStoragePath()
|
if r.storageClosed {
|
||||||
data, err := json.MarshalIndent(storage, "", " ")
|
return
|
||||||
|
}
|
||||||
|
if r.storageTimer != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||||
|
data, err := json.Marshal(storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(storagePath, data, 0600)
|
r.storageWriteMu.Lock()
|
||||||
|
defer r.storageWriteMu.Unlock()
|
||||||
|
|
||||||
|
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) flushStorageDirtyAsync() {
|
||||||
|
if err := r.flushStorageDirty(); err != nil {
|
||||||
|
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) flushStorageDirty() error {
|
||||||
|
r.storageMu.Lock()
|
||||||
|
if r.storageClosed {
|
||||||
|
r.storageTimer = nil
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !r.storageDirty {
|
||||||
|
r.storageTimer = nil
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
snapshot := cloneInterfaceMap(r.storageCache)
|
||||||
|
r.storageDirty = false
|
||||||
|
r.storageTimer = nil
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
|
||||||
|
if err := r.persistStorageSnapshot(snapshot); err != nil {
|
||||||
|
r.storageMu.Lock()
|
||||||
|
r.storageDirty = true
|
||||||
|
r.queueStorageFlushLocked(storageFlushRetryDelay)
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) flushStorageNow() error {
|
||||||
|
r.storageMu.Lock()
|
||||||
|
if r.storageTimer != nil {
|
||||||
|
r.storageTimer.Stop()
|
||||||
|
r.storageTimer = nil
|
||||||
|
}
|
||||||
|
if !r.storageLoaded || r.storageClosed {
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
snapshot := cloneInterfaceMap(r.storageCache)
|
||||||
|
r.storageDirty = false
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
|
||||||
|
return r.persistStorageSnapshot(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) closeStorageFlusher() {
|
||||||
|
r.storageMu.Lock()
|
||||||
|
r.storageClosed = true
|
||||||
|
r.storageDirty = false
|
||||||
|
if r.storageTimer != nil {
|
||||||
|
r.storageTimer.Stop()
|
||||||
|
r.storageTimer = nil
|
||||||
|
}
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
key := call.Arguments[0].String()
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
storage, err := r.loadStorage()
|
if err := r.ensureStorageLoaded(); err != nil {
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
value, exists := storage[key]
|
r.storageMu.RLock()
|
||||||
|
value, exists := r.storageCache[key]
|
||||||
|
r.storageMu.RUnlock()
|
||||||
if !exists {
|
if !exists {
|
||||||
if len(call.Arguments) > 1 {
|
if len(call.Arguments) > 1 {
|
||||||
return call.Arguments[1]
|
return call.Arguments[1]
|
||||||
@@ -73,7 +193,7 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -81,54 +201,68 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
|||||||
key := call.Arguments[0].String()
|
key := call.Arguments[0].String()
|
||||||
value := call.Arguments[1].Export()
|
value := call.Arguments[1].Export()
|
||||||
|
|
||||||
storage, err := r.loadStorage()
|
if err := r.ensureStorageLoaded(); err != nil {
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
storage[key] = value
|
r.storageMu.Lock()
|
||||||
|
if r.storageClosed {
|
||||||
if err := r.saveStorage(storage); err != nil {
|
r.storageMu.Unlock()
|
||||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
if existing, exists := r.storageCache[key]; exists {
|
||||||
|
if reflect.DeepEqual(existing, value) {
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.storageCache[key] = value
|
||||||
|
r.storageDirty = true
|
||||||
|
r.queueStorageFlushLocked(r.storageFlushDelay)
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
|
||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
key := call.Arguments[0].String()
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
storage, err := r.loadStorage()
|
if err := r.ensureStorageLoaded(); err != nil {
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(storage, key)
|
r.storageMu.Lock()
|
||||||
|
if r.storageClosed {
|
||||||
if err := r.saveStorage(storage); err != nil {
|
r.storageMu.Unlock()
|
||||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
if _, exists := r.storageCache[key]; !exists {
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
return r.vm.ToValue(true)
|
||||||
|
}
|
||||||
|
delete(r.storageCache, key)
|
||||||
|
r.storageDirty = true
|
||||||
|
r.queueStorageFlushLocked(r.storageFlushDelay)
|
||||||
|
r.storageMu.Unlock()
|
||||||
|
|
||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
func (r *extensionRuntime) getCredentialsPath() string {
|
||||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getSaltPath() string {
|
func (r *extensionRuntime) getSaltPath() string {
|
||||||
return filepath.Join(r.dataDir, ".cred_salt")
|
return filepath.Join(r.dataDir, ".cred_salt")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||||
saltPath := r.getSaltPath()
|
saltPath := r.getSaltPath()
|
||||||
|
|
||||||
salt, err := os.ReadFile(saltPath)
|
salt, err := os.ReadFile(saltPath)
|
||||||
@@ -148,7 +282,7 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
|||||||
return salt, nil
|
return salt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||||
salt, err := r.getOrCreateSalt()
|
salt, err := r.getOrCreateSalt()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -159,34 +293,64 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
|||||||
return hash[:], nil
|
return hash[:], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
||||||
|
r.credentialsMu.RLock()
|
||||||
|
if r.credentialsLoaded {
|
||||||
|
r.credentialsMu.RUnlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
r.credentialsMu.Lock()
|
||||||
|
defer r.credentialsMu.Unlock()
|
||||||
|
if r.credentialsLoaded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
credPath := r.getCredentialsPath()
|
credPath := r.getCredentialsPath()
|
||||||
data, err := os.ReadFile(credPath)
|
data, err := os.ReadFile(credPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return make(map[string]interface{}), nil
|
r.credentialsCache = make(map[string]interface{})
|
||||||
|
r.credentialsLoaded = true
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := r.getEncryptionKey()
|
key, err := r.getEncryptionKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||||
}
|
}
|
||||||
decrypted, err := decryptAES(data, key)
|
decrypted, err := decryptAES(data, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
|
return fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var creds map[string]interface{}
|
var creds map[string]interface{}
|
||||||
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if creds == nil {
|
||||||
|
creds = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
r.credentialsCache = creds
|
||||||
|
r.credentialsLoaded = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||||
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return creds, nil
|
r.credentialsMu.RLock()
|
||||||
|
defer r.credentialsMu.RUnlock()
|
||||||
|
return cloneInterfaceMap(r.credentialsCache), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||||
data, err := json.Marshal(creds)
|
data, err := json.Marshal(creds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -202,10 +366,18 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
credPath := r.getCredentialsPath()
|
credPath := r.getCredentialsPath()
|
||||||
return os.WriteFile(credPath, encrypted, 0600)
|
if err := os.WriteFile(credPath, encrypted, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.credentialsMu.Lock()
|
||||||
|
r.credentialsCache = cloneInterfaceMap(creds)
|
||||||
|
r.credentialsLoaded = true
|
||||||
|
r.credentialsMu.Unlock()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -216,8 +388,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
|||||||
key := call.Arguments[0].String()
|
key := call.Arguments[0].String()
|
||||||
value := call.Arguments[1].Export()
|
value := call.Arguments[1].Export()
|
||||||
|
|
||||||
creds, err := r.loadCredentials()
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -225,9 +396,12 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
creds[key] = value
|
r.credentialsMu.RLock()
|
||||||
|
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||||
|
r.credentialsMu.RUnlock()
|
||||||
|
nextCreds[key] = value
|
||||||
|
|
||||||
if err := r.saveCredentials(creds); err != nil {
|
if err := r.saveCredentials(nextCreds); err != nil {
|
||||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -240,20 +414,21 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
key := call.Arguments[0].String()
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
creds, err := r.loadCredentials()
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
value, exists := creds[key]
|
r.credentialsMu.RLock()
|
||||||
|
value, exists := r.credentialsCache[key]
|
||||||
|
r.credentialsMu.RUnlock()
|
||||||
if !exists {
|
if !exists {
|
||||||
if len(call.Arguments) > 1 {
|
if len(call.Arguments) > 1 {
|
||||||
return call.Arguments[1]
|
return call.Arguments[1]
|
||||||
@@ -264,22 +439,24 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
key := call.Arguments[0].String()
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
creds, err := r.loadCredentials()
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(creds, key)
|
r.credentialsMu.RLock()
|
||||||
|
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||||
|
r.credentialsMu.RUnlock()
|
||||||
|
delete(nextCreds, key)
|
||||||
|
|
||||||
if err := r.saveCredentials(creds); err != nil {
|
if err := r.saveCredentials(nextCreds); err != nil {
|
||||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -287,19 +464,20 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
key := call.Arguments[0].String()
|
key := call.Arguments[0].String()
|
||||||
|
|
||||||
creds, err := r.loadCredentials()
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, exists := creds[key]
|
r.credentialsMu.RLock()
|
||||||
|
_, exists := r.credentialsCache[key]
|
||||||
|
r.credentialsMu.RUnlock()
|
||||||
return r.vm.ToValue(exists)
|
return r.vm.ToValue(exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
result := runtime.storageSet(goja.FunctionCall{
|
||||||
|
Arguments: []goja.Value{
|
||||||
|
runtime.vm.ToValue(key),
|
||||||
|
runtime.vm.ToValue(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !result.ToBoolean() {
|
||||||
|
t.Fatalf("storage.set(%q) returned false", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read storage file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal storage file: %v", err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "storage-test",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "storage-test",
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := newExtensionRuntime(ext)
|
||||||
|
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||||
|
runtime.RegisterAPIs(goja.New())
|
||||||
|
|
||||||
|
setStorageValue(t, runtime, "k1", "v1")
|
||||||
|
setStorageValue(t, runtime, "k2", 2)
|
||||||
|
|
||||||
|
storagePath := filepath.Join(ext.DataDir, "storage.json")
|
||||||
|
deadline := time.Now().Add(1500 * time.Millisecond)
|
||||||
|
|
||||||
|
var raw []byte
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
data, err := os.ReadFile(storagePath)
|
||||||
|
if err == nil {
|
||||||
|
raw = data
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
t.Fatalf("storage.json was not written within timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal storage file: %v", err)
|
||||||
|
}
|
||||||
|
if parsed["k1"] != "v1" {
|
||||||
|
t.Fatalf("expected k1=v1, got %v", parsed["k1"])
|
||||||
|
}
|
||||||
|
if parsed["k2"] != float64(2) {
|
||||||
|
t.Fatalf("expected k2=2, got %v", parsed["k2"])
|
||||||
|
}
|
||||||
|
if bytes.Contains(raw, []byte("\n")) {
|
||||||
|
t.Fatalf("expected compact JSON without indentation, got: %q", string(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||||
|
ext := &loadedExtension{
|
||||||
|
ID: "unload-storage-test",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "unload-storage-test",
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
VM: goja.New(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := newExtensionRuntime(ext)
|
||||||
|
runtime.storageFlushDelay = time.Hour
|
||||||
|
runtime.RegisterAPIs(ext.VM)
|
||||||
|
ext.runtime = runtime
|
||||||
|
|
||||||
|
manager := &extensionManager{
|
||||||
|
extensions: map[string]*loadedExtension{
|
||||||
|
ext.ID: ext,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
setStorageValue(t, runtime, "persist_on_unload", true)
|
||||||
|
|
||||||
|
if err := manager.UnloadExtension(ext.ID); err != nil {
|
||||||
|
t.Fatalf("UnloadExtension failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storagePath := filepath.Join(ext.DataDir, "storage.json")
|
||||||
|
parsed := readStorageMap(t, storagePath)
|
||||||
|
if parsed["persist_on_unload"] != true {
|
||||||
|
t.Fatalf("expected pending storage value to be flushed on unload, got %v", parsed["persist_on_unload"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides Utility functions for extension runtime
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,9 +16,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Utility Functions ====================
|
func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||||
|
|
||||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -27,7 +24,7 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -39,7 +36,7 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -48,7 +45,7 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -57,7 +54,7 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -69,7 +66,7 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -81,7 +78,7 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue([]byte{})
|
return r.vm.ToValue([]byte{})
|
||||||
}
|
}
|
||||||
@@ -133,7 +130,7 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(jsArray)
|
return r.vm.ToValue(jsArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -148,7 +145,7 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -163,7 +160,7 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(data))
|
return r.vm.ToValue(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -190,7 +187,7 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -225,7 +222,7 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||||
length := 32
|
length := 32
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||||
@@ -248,35 +245,35 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||||
return r.vm.ToValue(getRandomUserAgent())
|
return r.vm.ToValue(getRandomUserAgent())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||||
parts := make([]string, len(args))
|
parts := make([]string, len(args))
|
||||||
for i, arg := range args {
|
for i, arg := range args {
|
||||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||||
@@ -284,7 +281,7 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -292,7 +289,7 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(sanitizeFilename(input))
|
return r.vm.ToValue(sanitizeFilename(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||||
gobackendObj := vm.Get("gobackend")
|
gobackendObj := vm.Get("gobackend")
|
||||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||||
gobackendObj = vm.NewObject()
|
gobackendObj = vm.NewObject()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides extension settings storage
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,7 @@ const (
|
|||||||
CategoryIntegration = "integration"
|
CategoryIntegration = "integration"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StoreExtension struct {
|
type storeExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
@@ -40,7 +41,7 @@ type StoreExtension struct {
|
|||||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getDisplayName() string {
|
func (e *storeExtension) getDisplayName() string {
|
||||||
if e.DisplayName != "" {
|
if e.DisplayName != "" {
|
||||||
return e.DisplayName
|
return e.DisplayName
|
||||||
}
|
}
|
||||||
@@ -50,35 +51,34 @@ func (e *StoreExtension) getDisplayName() string {
|
|||||||
return e.Name
|
return e.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getDownloadURL() string {
|
func (e *storeExtension) getDownloadURL() string {
|
||||||
if e.DownloadURL != "" {
|
if e.DownloadURL != "" {
|
||||||
return e.DownloadURL
|
return e.DownloadURL
|
||||||
}
|
}
|
||||||
return e.DownloadURLAlt
|
return e.DownloadURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getIconURL() string {
|
func (e *storeExtension) getIconURL() string {
|
||||||
if e.IconURL != "" {
|
if e.IconURL != "" {
|
||||||
return e.IconURL
|
return e.IconURL
|
||||||
}
|
}
|
||||||
return e.IconURLAlt
|
return e.IconURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getMinAppVersion() string {
|
func (e *storeExtension) getMinAppVersion() string {
|
||||||
if e.MinAppVersion != "" {
|
if e.MinAppVersion != "" {
|
||||||
return e.MinAppVersion
|
return e.MinAppVersion
|
||||||
}
|
}
|
||||||
return e.MinAppVersionAlt
|
return e.MinAppVersionAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoreRegistry struct {
|
type storeRegistry struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Extensions []StoreExtension `json:"extensions"`
|
Extensions []storeExtension `json:"extensions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreExtensionResponse is the normalized response sent to Flutter
|
type storeExtensionResponse struct {
|
||||||
type StoreExtensionResponse struct {
|
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
|
|||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||||
return StoreExtensionResponse{
|
resp := storeExtensionResponse{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
DisplayName: e.getDisplayName(),
|
DisplayName: e.getDisplayName(),
|
||||||
@@ -108,55 +108,85 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
|||||||
DownloadURL: e.getDownloadURL(),
|
DownloadURL: e.getDownloadURL(),
|
||||||
IconURL: e.getIconURL(),
|
IconURL: e.getIconURL(),
|
||||||
Category: e.Category,
|
Category: e.Category,
|
||||||
Tags: e.Tags,
|
|
||||||
Downloads: e.Downloads,
|
Downloads: e.Downloads,
|
||||||
UpdatedAt: e.UpdatedAt,
|
UpdatedAt: e.UpdatedAt,
|
||||||
MinAppVersion: e.getMinAppVersion(),
|
MinAppVersion: e.getMinAppVersion(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
resp.Tags = append([]string(nil), e.Tags...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionStore struct {
|
type extensionStore struct {
|
||||||
registryURL string
|
registryURL string
|
||||||
cacheDir string
|
cacheDir string
|
||||||
cache *StoreRegistry
|
cache *storeRegistry
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cacheTime time.Time
|
cacheTime time.Time
|
||||||
cacheTTL time.Duration
|
cacheTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
extensionStore *ExtensionStore
|
globalExtensionStore *extensionStore
|
||||||
extensionStoreMu sync.Mutex
|
extensionStoreMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
cacheTTL = 30 * time.Minute
|
||||||
cacheTTL = 30 * time.Minute
|
cacheFileName = "store_cache.json"
|
||||||
cacheFileName = "store_cache.json"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
func initExtensionStore(cacheDir string) *extensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
|
|
||||||
if extensionStore == nil {
|
if globalExtensionStore == nil {
|
||||||
extensionStore = &ExtensionStore{
|
globalExtensionStore = &extensionStore{
|
||||||
registryURL: defaultRegistryURL,
|
registryURL: "",
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
}
|
}
|
||||||
extensionStore.loadDiskCache()
|
globalExtensionStore.loadDiskCache()
|
||||||
}
|
}
|
||||||
return extensionStore
|
return globalExtensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetExtensionStore() *ExtensionStore {
|
func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
if s.registryURL == registryURL {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.registryURL = registryURL
|
||||||
|
s.cache = nil
|
||||||
|
s.cacheTime = time.Time{}
|
||||||
|
|
||||||
|
if s.cacheDir != "" {
|
||||||
|
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||||
|
os.Remove(cachePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *extensionStore) getRegistryURL() string {
|
||||||
|
s.cacheMu.RLock()
|
||||||
|
defer s.cacheMu.RUnlock()
|
||||||
|
return s.registryURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func getExtensionStore() *extensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
return extensionStore
|
return globalExtensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) loadDiskCache() {
|
func (s *extensionStore) loadDiskCache() {
|
||||||
if s.cacheDir == "" {
|
if s.cacheDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -168,7 +198,7 @@ func (s *ExtensionStore) loadDiskCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cacheData struct {
|
var cacheData struct {
|
||||||
Registry StoreRegistry `json:"registry"`
|
Registry storeRegistry `json:"registry"`
|
||||||
CacheTime int64 `json:"cache_time"`
|
CacheTime int64 `json:"cache_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,13 +211,13 @@ func (s *ExtensionStore) loadDiskCache() {
|
|||||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) saveDiskCache() {
|
func (s *extensionStore) saveDiskCache() {
|
||||||
if s.cacheDir == "" || s.cache == nil {
|
if s.cacheDir == "" || s.cache == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheData := struct {
|
cacheData := struct {
|
||||||
Registry StoreRegistry `json:"registry"`
|
Registry storeRegistry `json:"registry"`
|
||||||
CacheTime int64 `json:"cache_time"`
|
CacheTime int64 `json:"cache_time"`
|
||||||
}{
|
}{
|
||||||
Registry: *s.cache,
|
Registry: *s.cache,
|
||||||
@@ -203,10 +233,14 @@ func (s *ExtensionStore) saveDiskCache() {
|
|||||||
os.WriteFile(cachePath, data, 0644)
|
os.WriteFile(cachePath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
if s.registryURL == "" {
|
||||||
|
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||||
|
}
|
||||||
|
|
||||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||||
return s.cache, nil
|
return s.cache, nil
|
||||||
@@ -218,7 +252,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
|
|
||||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||||
resp, err := client.Get(s.registryURL)
|
resp, err := client.Get(s.registryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
@@ -242,7 +276,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var registry StoreRegistry
|
var registry storeRegistry
|
||||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||||
}
|
}
|
||||||
@@ -255,13 +289,13 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return ®istry, nil
|
return ®istry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.fetchRegistry(forceRefresh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := GetExtensionManager()
|
manager := getExtensionManager()
|
||||||
installed := make(map[string]string) // id -> version
|
installed := make(map[string]string) // id -> version
|
||||||
|
|
||||||
if manager != nil {
|
if manager != nil {
|
||||||
@@ -270,29 +304,32 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||||
for i, ext := range registry.Extensions {
|
|
||||||
resp := ext.ToResponse()
|
|
||||||
|
|
||||||
|
result := make([]storeExtensionResponse, 0, len(registry.Extensions))
|
||||||
|
for i := range registry.Extensions {
|
||||||
|
ext := ®istry.Extensions[i]
|
||||||
|
resp := ext.toResponse()
|
||||||
if installedVersion, ok := installed[ext.ID]; ok {
|
if installedVersion, ok := installed[ext.ID]; ok {
|
||||||
resp.IsInstalled = true
|
resp.IsInstalled = true
|
||||||
resp.InstalledVersion = installedVersion
|
resp.InstalledVersion = installedVersion
|
||||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
result[i] = resp
|
result = append(result, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.fetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ext *StoreExtension
|
var ext *storeExtension
|
||||||
for _, e := range registry.Extensions {
|
for _, e := range registry.Extensions {
|
||||||
if e.ID == extensionID {
|
if e.ID == extensionID {
|
||||||
ext = &e
|
ext = &e
|
||||||
@@ -310,7 +347,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
|
|
||||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Minute}
|
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||||
resp, err := client.Get(ext.getDownloadURL())
|
resp, err := client.Get(ext.getDownloadURL())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download: %w", err)
|
return fmt.Errorf("failed to download: %w", err)
|
||||||
@@ -337,6 +374,68 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveRegistryURL(input string) (string, error) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if input == "" {
|
||||||
|
return "", fmt.Errorf("registry URL is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(input, "raw.githubusercontent.com") {
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghPrefix = "https://github.com/"
|
||||||
|
if !strings.HasPrefix(input, ghPrefix) {
|
||||||
|
const ghPrefixHTTP = "http://github.com/"
|
||||||
|
if strings.HasPrefix(input, ghPrefixHTTP) {
|
||||||
|
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
||||||
|
} else {
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := input[len(ghPrefix):]
|
||||||
|
parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
|
||||||
|
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||||
|
return "", fmt.Errorf("invalid GitHub URL: expected github.com/<owner>/<repo>")
|
||||||
|
}
|
||||||
|
owner := parts[0]
|
||||||
|
repo := strings.TrimSuffix(parts[1], ".git")
|
||||||
|
|
||||||
|
branch := resolveGitHubDefaultBranch(owner, repo)
|
||||||
|
|
||||||
|
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
|
||||||
|
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||||
|
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
||||||
|
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||||
|
|
||||||
|
resp, err := client.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err)
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo)
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
var info struct {
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
|
||||||
|
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo)
|
||||||
|
return "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.DefaultBranch
|
||||||
|
}
|
||||||
|
|
||||||
func requireHTTPSURL(rawURL string, context string) error {
|
func requireHTTPSURL(rawURL string, context string) error {
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return fmt.Errorf("%s URL is empty", context)
|
return fmt.Errorf("%s URL is empty", context)
|
||||||
@@ -351,7 +450,7 @@ func requireHTTPSURL(rawURL string, context string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetCategories() []string {
|
func (s *extensionStore) getCategories() []string {
|
||||||
return []string{
|
return []string{
|
||||||
CategoryMetadata,
|
CategoryMetadata,
|
||||||
CategoryDownload,
|
CategoryDownload,
|
||||||
@@ -361,8 +460,8 @@ func (s *ExtensionStore) GetCategories() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
|
||||||
extensions, err := s.GetExtensionsWithStatus()
|
extensions, err := s.getExtensionsWithStatus(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -371,22 +470,19 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return extensions, nil
|
return extensions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []StoreExtensionResponse
|
result := make([]storeExtensionResponse, 0, len(extensions))
|
||||||
queryLower := toLower(query)
|
queryLower := toLower(query)
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
// Filter by category
|
|
||||||
if category != "" && ext.Category != category {
|
if category != "" && ext.Category != category {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by query
|
|
||||||
if query != "" {
|
if query != "" {
|
||||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Author, queryLower) {
|
!containsIgnoreCase(ext.Author, queryLower) {
|
||||||
// Check tags
|
|
||||||
found := false
|
found := false
|
||||||
for _, tag := range ext.Tags {
|
for _, tag := range ext.Tags {
|
||||||
if containsIgnoreCase(tag, queryLower) {
|
if containsIgnoreCase(tag, queryLower) {
|
||||||
@@ -406,7 +502,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) ClearCache() {
|
func (s *extensionStore) clearCache() {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
@@ -421,7 +517,6 @@ func (s *ExtensionStore) ClearCache() {
|
|||||||
LogInfo("ExtensionStore", "Cache cleared")
|
LogInfo("ExtensionStore", "Cache cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: case-insensitive contains
|
|
||||||
func containsIgnoreCase(s, substr string) bool {
|
func containsIgnoreCase(s, substr string) bool {
|
||||||
return containsStr(toLower(s), substr)
|
return containsStr(toLower(s), substr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestIsDomainAllowed(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||||
// Create a mock extension with limited network permissions
|
// Create a mock extension with limited network permissions
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -110,7 +110,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||||
@@ -132,7 +132,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -143,7 +143,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
validPath, err := runtime.validatePath("test.txt")
|
validPath, err := runtime.validatePath("test.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -177,7 +177,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
t.Error("Expected absolute path to be blocked")
|
t.Error("Expected absolute path to be blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
extNoFile := &LoadedExtension{
|
extNoFile := &loadedExtension{
|
||||||
ID: "test-ext-no-file",
|
ID: "test-ext-no-file",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext-no-file",
|
Name: "test-ext-no-file",
|
||||||
@@ -187,7 +187,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
},
|
},
|
||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
}
|
}
|
||||||
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
runtimeNoFile := newExtensionRuntime(extNoFile)
|
||||||
_, err = runtimeNoFile.validatePath("test.txt")
|
_, err = runtimeNoFile.validatePath("test.txt")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected file access to be denied without file permission")
|
t.Error("Expected file access to be denied without file permission")
|
||||||
@@ -195,7 +195,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -203,7 +203,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||||
// Create extension with limited network permissions
|
// Create extension with limited network permissions
|
||||||
ext := &LoadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -254,7 +254,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
|
|
||||||
privateIPs := []string{
|
privateIPs := []string{
|
||||||
"http://localhost/admin",
|
"http://localhost/admin",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides timeout execution for extension JS code
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,6 +20,10 @@ func (e *JSExecutionError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
|
if vm == nil {
|
||||||
|
return nil, fmt.Errorf("extension runtime unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = DefaultJSTimeout
|
timeout = DefaultJSTimeout
|
||||||
}
|
}
|
||||||
@@ -50,7 +53,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
GoLog("[extensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
|
|
||||||
vm.Interrupt("execution timeout")
|
vm.Interrupt("execution timeout")
|
||||||
|
|
||||||
|
// MUST wait for the goroutine to finish before returning.
|
||||||
|
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
||||||
|
// is still executing JS (e.g. blocked on an HTTP call), the next
|
||||||
|
// caller will access the VM concurrently and crash with a nil
|
||||||
|
// pointer dereference.
|
||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
@@ -79,7 +87,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
Message: "execution timeout exceeded",
|
Message: "execution timeout exceeded",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}
|
}
|
||||||
case <-time.After(1 * time.Second):
|
case <-time.After(60 * time.Second):
|
||||||
|
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||||
|
// Log a warning — the VM should NOT be reused after this.
|
||||||
|
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||||
return nil, &JSExecutionError{
|
return nil, &JSExecutionError{
|
||||||
Message: "execution timeout exceeded (force)",
|
Message: "execution timeout exceeded (force)",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
@@ -93,8 +104,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
result, err := RunWithTimeout(vm, script, timeout)
|
result, err := RunWithTimeout(vm, script, timeout)
|
||||||
|
|
||||||
// Clear any interrupt state so VM can be reused
|
if vm != nil {
|
||||||
vm.ClearInterrupt()
|
vm.ClearInterrupt()
|
||||||
|
}
|
||||||
|
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,28 @@ module github.com/zarz/spotiflac_android/go_backend
|
|||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.26.0
|
toolchain go1.25.8
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
|
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/net v0.52.0
|
||||||
|
golang.org/x/text v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.49.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,63 +1,51 @@
|
|||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/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 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
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 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
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 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-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.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.4+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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/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 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -30,13 +31,23 @@ func getRandomUserAgent() string {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second
|
DefaultTimeout = 60 * time.Second
|
||||||
DownloadTimeout = 120 * time.Second
|
DownloadTimeout = 24 * time.Hour
|
||||||
SongLinkTimeout = 30 * time.Second
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second
|
DefaultRetryDelay = 1 * time.Second
|
||||||
Second = time.Second
|
Second = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type NetworkCompatibilityOptions struct {
|
||||||
|
AllowHTTP bool
|
||||||
|
InsecureTLS bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
networkCompatibilityMu sync.RWMutex
|
||||||
|
networkCompatibilityOptions NetworkCompatibilityOptions
|
||||||
|
)
|
||||||
|
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -55,9 +66,6 @@ var sharedTransport = &http.Transport{
|
|||||||
DisableCompression: true,
|
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{
|
var metadataTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -77,27 +85,25 @@ var metadataTransport = &http.Transport{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sharedClient = &http.Client{
|
var sharedClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: newCompatibilityTransport(sharedTransport),
|
||||||
Timeout: DefaultTimeout,
|
Timeout: DefaultTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadClient = &http.Client{
|
var downloadClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: newCompatibilityTransport(sharedTransport),
|
||||||
Timeout: DownloadTimeout,
|
Timeout: DownloadTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: newCompatibilityTransport(sharedTransport),
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
|
|
||||||
// Use this for API calls that should not be affected by download traffic.
|
|
||||||
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: metadataTransport,
|
Transport: newCompatibilityTransport(metadataTransport),
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +121,109 @@ func CloseIdleConnections() {
|
|||||||
metadataTransport.CloseIdleConnections()
|
metadataTransport.CloseIdleConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also checks for ISP blocking on errors
|
func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
|
||||||
|
networkCompatibilityMu.Lock()
|
||||||
|
networkCompatibilityOptions = NetworkCompatibilityOptions{
|
||||||
|
AllowHTTP: allowHTTP,
|
||||||
|
InsecureTLS: insecureTLS,
|
||||||
|
}
|
||||||
|
networkCompatibilityMu.Unlock()
|
||||||
|
|
||||||
|
applyTLSCompatibility(sharedTransport, insecureTLS)
|
||||||
|
applyTLSCompatibility(metadataTransport, insecureTLS)
|
||||||
|
CloseIdleConnections()
|
||||||
|
|
||||||
|
GoLog("[HTTP] Network compatibility options updated: allow_http=%v insecure_tls=%v\n", allowHTTP, insecureTLS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
|
||||||
|
networkCompatibilityMu.RLock()
|
||||||
|
defer networkCompatibilityMu.RUnlock()
|
||||||
|
return networkCompatibilityOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
|
||||||
|
if insecureTLS {
|
||||||
|
cfg := &tls.Config{InsecureSkipVerify: true}
|
||||||
|
if transport.TLSClientConfig != nil {
|
||||||
|
cfg = transport.TLSClientConfig.Clone()
|
||||||
|
cfg.InsecureSkipVerify = true
|
||||||
|
}
|
||||||
|
transport.TLSClientConfig = cfg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.TLSClientConfig = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type compatibilityTransport struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCompatibilityTransport(base http.RoundTripper) http.RoundTripper {
|
||||||
|
return &compatibilityTransport{base: base}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *compatibilityTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if req == nil || req.URL == nil {
|
||||||
|
return t.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := GetNetworkCompatibilityOptions()
|
||||||
|
if !opts.AllowHTTP || req.URL.Scheme != "https" {
|
||||||
|
return t.base.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility mode should prefer HTTPS and only fallback to HTTP on
|
||||||
|
// transport-level failures. Forcing HTTP unconditionally can trigger
|
||||||
|
// redirect loops (http -> https) on providers that enforce HTTPS.
|
||||||
|
resp, err := t.base.RoundTrip(req)
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canFallbackToHTTP(req) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackReq, cloneErr := cloneRequestWithHTTPScheme(req, "http")
|
||||||
|
if cloneErr != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[HTTP] HTTPS request failed for %s, retrying over HTTP: %v\n", req.URL.Host, err)
|
||||||
|
return t.base.RoundTrip(fallbackReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canFallbackToHTTP(req *http.Request) bool {
|
||||||
|
if req == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToUpper(req.Method) {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodDelete:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return req.GetBody != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request, error) {
|
||||||
|
reqCopy := req.Clone(req.Context())
|
||||||
|
if req.Body != nil && req.GetBody != nil {
|
||||||
|
bodyCopy, err := req.GetBody()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reqCopy.Body = bodyCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
urlCopy := *req.URL
|
||||||
|
urlCopy.Scheme = scheme
|
||||||
|
reqCopy.URL = &urlCopy
|
||||||
|
return reqCopy, nil
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -125,7 +233,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
|
|||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryConfig holds configuration for retry logic
|
|
||||||
type RetryConfig struct {
|
type RetryConfig struct {
|
||||||
MaxRetries int
|
MaxRetries int
|
||||||
InitialDelay time.Duration
|
InitialDelay time.Duration
|
||||||
@@ -145,7 +252,6 @@ func DefaultRetryConfig() RetryConfig {
|
|||||||
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()
|
|
||||||
|
|
||||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
@@ -155,8 +261,8 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
|
||||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
|
||||||
}
|
}
|
||||||
|
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
@@ -187,14 +293,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for ISP blocking via HTTP status codes
|
|
||||||
// Some ISPs return 403 or 451 when blocking content
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||||
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
|
|
||||||
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",
|
||||||
@@ -233,11 +336,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
|||||||
return min(nextDelay, config.MaxDelay)
|
return min(nextDelay, config.MaxDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns 60 seconds as default if header is missing or invalid
|
// Returns 0 if the header is missing or invalid so callers can keep their
|
||||||
|
// normal exponential backoff instead of stalling for an arbitrary minute.
|
||||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||||
retryAfter := resp.Header.Get("Retry-After")
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
if retryAfter == "" {
|
if retryAfter == "" {
|
||||||
return 60 * time.Second // Default wait time
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
@@ -251,7 +355,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 60 * time.Second // Default
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
@@ -376,7 +480,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check error message patterns for common ISP blocking indicators
|
|
||||||
blockingPatterns := []struct {
|
blockingPatterns := []struct {
|
||||||
pattern string
|
pattern string
|
||||||
reason string
|
reason string
|
||||||
@@ -405,7 +508,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
if ispErr != nil {
|
if ispErr != nil {
|
||||||
@@ -419,7 +521,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDomain extracts the domain from a URL string
|
|
||||||
func extractDomain(rawURL string) string {
|
func extractDomain(rawURL string) string {
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -441,7 +542,6 @@ func extractDomain(rawURL string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -6,17 +6,10 @@ import (
|
|||||||
"net/http"
|
"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 {
|
func GetCloudflareBypassClient() *http.Client {
|
||||||
return sharedClient
|
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) {
|
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
resp, err := sharedClient.Do(req)
|
resp, err := sharedClient.Do(req)
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import (
|
|||||||
"golang.org/x/net/http2"
|
"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 {
|
type utlsTransport struct {
|
||||||
dialer *net.Dialer
|
dialer *net.Dialer
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -91,7 +89,6 @@ func (t *utlsTransport) getPort(u *url.URL) string {
|
|||||||
return "80"
|
return "80"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloudflare bypass client using uTLS Chrome fingerprint
|
|
||||||
var cloudflareBypassTransport = newUTLSTransport()
|
var cloudflareBypassTransport = newUTLSTransport()
|
||||||
|
|
||||||
var cloudflareBypassClient = &http.Client{
|
var cloudflareBypassClient = &http.Client{
|
||||||
@@ -99,22 +96,15 @@ var cloudflareBypassClient = &http.Client{
|
|||||||
Timeout: DefaultTimeout,
|
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 {
|
func GetCloudflareBypassClient() *http.Client {
|
||||||
return cloudflareBypassClient
|
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) {
|
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
// Try with standard client first
|
|
||||||
resp, err := sharedClient.Do(req)
|
resp, err := sharedClient.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check for Cloudflare challenge page (403 with specific markers)
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -138,16 +128,13 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
if isCloudflare {
|
if isCloudflare {
|
||||||
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||||
|
|
||||||
// Clone request for retry
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
// Retry with uTLS Chrome fingerprint
|
|
||||||
return cloudflareBypassClient.Do(reqCopy)
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not Cloudflare, return original response (recreate body)
|
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
Status: resp.Status,
|
Status: resp.Status,
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
@@ -158,7 +145,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if error might be TLS-related (Cloudflare blocking)
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||||
strings.Contains(errStr, "handshake") ||
|
strings.Contains(errStr, "handshake") ||
|
||||||
@@ -168,11 +154,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
if tlsRelated {
|
if tlsRelated {
|
||||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||||
|
|
||||||
// Clone request for retry
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
// Retry with uTLS Chrome fingerprint
|
|
||||||
return cloudflareBypassClient.Do(reqCopy)
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
"time"
|
"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 {
|
type IDHSClient struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
@@ -22,13 +20,11 @@ var (
|
|||||||
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
||||||
)
|
)
|
||||||
|
|
||||||
// IDHSSearchRequest represents the request body for IDHS API
|
|
||||||
type IDHSSearchRequest struct {
|
type IDHSSearchRequest struct {
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Adapters []string `json:"adapters,omitempty"`
|
Adapters []string `json:"adapters,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDHSSearchResponse represents the response from IDHS API
|
|
||||||
type IDHSSearchResponse struct {
|
type IDHSSearchResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"` // song, album, artist, podcast, show
|
Type string `json:"type"` // song, album, artist, podcast, show
|
||||||
@@ -41,7 +37,6 @@ type IDHSSearchResponse struct {
|
|||||||
Links []IDHSLink `json:"links"`
|
Links []IDHSLink `json:"links"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDHSLink represents a link to a streaming platform
|
|
||||||
type IDHSLink struct {
|
type IDHSLink struct {
|
||||||
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -49,7 +44,6 @@ type IDHSLink struct {
|
|||||||
NotAvailable bool `json:"notAvailable,omitempty"`
|
NotAvailable bool `json:"notAvailable,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIDHSClient creates a new IDHS client
|
|
||||||
func NewIDHSClient() *IDHSClient {
|
func NewIDHSClient() *IDHSClient {
|
||||||
idhsClientOnce.Do(func() {
|
idhsClientOnce.Do(func() {
|
||||||
globalIDHSClient = &IDHSClient{
|
globalIDHSClient = &IDHSClient{
|
||||||
@@ -59,7 +53,6 @@ func NewIDHSClient() *IDHSClient {
|
|||||||
return globalIDHSClient
|
return globalIDHSClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search converts a music link to links on other platforms
|
|
||||||
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
||||||
idhsRateLimiter.WaitForSlot()
|
idhsRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
@@ -113,11 +106,9 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
|
|
||||||
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
|
|
||||||
// Request only the platforms we need
|
|
||||||
adapters := []string{"tidal", "deezer"}
|
adapters := []string{"tidal", "deezer"}
|
||||||
|
|
||||||
result, err := c.Search(spotifyURL, adapters)
|
result, err := c.Search(spotifyURL, adapters)
|
||||||
@@ -151,11 +142,9 @@ func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAv
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailabilityFromDeezer checks track availability using IDHS
|
|
||||||
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
// Request only the platforms we need
|
|
||||||
adapters := []string{"spotify", "tidal"}
|
adapters := []string{"spotify", "tidal"}
|
||||||
|
|
||||||
result, err := c.Search(deezerURL, adapters)
|
result, err := c.Search(deezerURL, adapters)
|
||||||
|
|||||||
@@ -1,36 +1,43 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LibraryScanResult represents metadata from a scanned audio file
|
|
||||||
type LibraryScanResult struct {
|
type LibraryScanResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TrackName string `json:"trackName"`
|
TrackName string `json:"trackName"`
|
||||||
ArtistName string `json:"artistName"`
|
ArtistName string `json:"artistName"`
|
||||||
AlbumName string `json:"albumName"`
|
AlbumName string `json:"albumName"`
|
||||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
CoverPath string `json:"coverPath,omitempty"`
|
CoverPath string `json:"coverPath,omitempty"`
|
||||||
ScannedAt string `json:"scannedAt"`
|
ScannedAt string `json:"scannedAt"`
|
||||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
TrackNumber int `json:"trackNumber,omitempty"`
|
TrackNumber int `json:"trackNumber,omitempty"`
|
||||||
DiscNumber int `json:"discNumber,omitempty"`
|
TotalTracks int `json:"totalTracks,omitempty"`
|
||||||
Duration int `json:"duration,omitempty"`
|
DiscNumber int `json:"discNumber,omitempty"`
|
||||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
TotalDiscs int `json:"totalDiscs,omitempty"`
|
||||||
BitDepth int `json:"bitDepth,omitempty"`
|
Duration int `json:"duration,omitempty"`
|
||||||
SampleRate int `json:"sampleRate,omitempty"`
|
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
BitDepth int `json:"bitDepth,omitempty"`
|
||||||
Genre string `json:"genre,omitempty"`
|
SampleRate int `json:"sampleRate,omitempty"`
|
||||||
Format string `json:"format,omitempty"`
|
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Copyright string `json:"copyright,omitempty"`
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
|
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LibraryScanProgress struct {
|
type LibraryScanProgress struct {
|
||||||
@@ -42,7 +49,6 @@ type LibraryScanProgress struct {
|
|||||||
IsComplete bool `json:"is_complete"`
|
IsComplete bool `json:"is_complete"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementalScanResult contains results of an incremental library scan
|
|
||||||
type IncrementalScanResult struct {
|
type IncrementalScanResult struct {
|
||||||
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
||||||
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
||||||
@@ -65,6 +71,57 @@ var supportedAudioFormats = map[string]bool{
|
|||||||
".mp3": true,
|
".mp3": true,
|
||||||
".opus": true,
|
".opus": true,
|
||||||
".ogg": true,
|
".ogg": true,
|
||||||
|
".ape": true,
|
||||||
|
".wv": true,
|
||||||
|
".mpc": true,
|
||||||
|
".cue": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type libraryAudioFileInfo struct {
|
||||||
|
path string
|
||||||
|
modTime int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type scannedCueFileInfo struct {
|
||||||
|
sheet *CueSheet
|
||||||
|
audioPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||||
|
var files []libraryAudioFileInfo
|
||||||
|
|
||||||
|
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if !supportedAudioFormats[ext] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, libraryAudioFileInfo{
|
||||||
|
path: path,
|
||||||
|
modTime: info.ModTime().UnixMilli(),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetLibraryCoverCacheDir(cacheDir string) {
|
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||||
@@ -98,32 +155,12 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
cancelCh := libraryScanCancel
|
cancelCh := libraryScanCancel
|
||||||
libraryScanCancelMu.Unlock()
|
libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
var audioFiles []string
|
audioFileInfos, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||||
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 {
|
if err != nil {
|
||||||
return "[]", err
|
return "[]", err
|
||||||
}
|
}
|
||||||
|
|
||||||
totalFiles := len(audioFiles)
|
totalFiles := len(audioFileInfos)
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
libraryScanProgress.TotalFiles = totalFiles
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
@@ -141,7 +178,29 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
for i, filePath := range audioFiles {
|
cueReferencedAudioFiles := make(map[string]bool)
|
||||||
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
|
|
||||||
|
for _, fileInfo := range audioFileInfos {
|
||||||
|
filePath := fileInfo.path
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext == ".cue" {
|
||||||
|
sheet, err := ParseCueFile(filePath)
|
||||||
|
if err == nil && sheet.FileName != "" {
|
||||||
|
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
|
||||||
|
if audioPath != "" {
|
||||||
|
parsedCueFiles[filePath] = scannedCueFileInfo{
|
||||||
|
sheet: sheet,
|
||||||
|
audioPath: audioPath,
|
||||||
|
}
|
||||||
|
cueReferencedAudioFiles[audioPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, fileInfo := range audioFileInfos {
|
||||||
|
filePath := fileInfo.path
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
return "[]", fmt.Errorf("scan cancelled")
|
return "[]", fmt.Errorf("scan cancelled")
|
||||||
@@ -154,7 +213,40 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
result, err := scanAudioFile(filePath, scanTime)
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
|
if ext == ".cue" {
|
||||||
|
var cueResults []LibraryScanResult
|
||||||
|
cueInfo, ok := parsedCueFiles[filePath]
|
||||||
|
if ok {
|
||||||
|
cueResults, err = scanCueSheetForLibrary(
|
||||||
|
filePath,
|
||||||
|
cueInfo.sheet,
|
||||||
|
cueInfo.audioPath,
|
||||||
|
"",
|
||||||
|
fileInfo.modTime,
|
||||||
|
"",
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cueResults, err = ScanCueFileForLibrary(filePath, scanTime)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, cueResults...)
|
||||||
|
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if cueReferencedAudioFiles[filePath] {
|
||||||
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||||
@@ -180,7 +272,19 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
|
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
|
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||||
|
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||||
|
|
||||||
result := &LibraryScanResult{
|
result := &LibraryScanResult{
|
||||||
ID: generateLibraryID(filePath),
|
ID: generateLibraryID(filePath),
|
||||||
@@ -189,16 +293,22 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
Format: strings.TrimPrefix(ext, "."),
|
Format: strings.TrimPrefix(ext, "."),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file modification time
|
if knownModTime > 0 {
|
||||||
if info, err := os.Stat(filePath); err == nil {
|
result.FileModTime = knownModTime
|
||||||
|
} else if info, err := os.Stat(filePath); err == nil {
|
||||||
result.FileModTime = info.ModTime().UnixMilli()
|
result.FileModTime = info.ModTime().UnixMilli()
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryCoverCacheMu.RLock()
|
libraryCoverCacheMu.RLock()
|
||||||
coverCacheDir := libraryCoverCacheDir
|
coverCacheDir := libraryCoverCacheDir
|
||||||
libraryCoverCacheMu.RUnlock()
|
libraryCoverCacheMu.RUnlock()
|
||||||
if coverCacheDir != "" && ext != ".m4a" {
|
if coverCacheDir != "" {
|
||||||
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
coverPath, err := SaveCoverToCacheWithHintAndKey(
|
||||||
|
filePath,
|
||||||
|
displayNameHint,
|
||||||
|
coverCacheDir,
|
||||||
|
coverCacheKey,
|
||||||
|
)
|
||||||
if err == nil && coverPath != "" {
|
if err == nil && coverPath != "" {
|
||||||
result.CoverPath = coverPath
|
result.CoverPath = coverPath
|
||||||
}
|
}
|
||||||
@@ -206,22 +316,52 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".flac":
|
case ".flac":
|
||||||
return scanFLACFile(filePath, result)
|
return scanFLACFile(filePath, result, displayNameHint)
|
||||||
case ".m4a":
|
case ".m4a":
|
||||||
return scanM4AFile(filePath, result)
|
return scanM4AFile(filePath, result, displayNameHint)
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
return scanMP3File(filePath, result)
|
return scanMP3File(filePath, result, displayNameHint)
|
||||||
case ".opus", ".ogg":
|
case ".opus", ".ogg":
|
||||||
return scanOggFile(filePath, result)
|
return scanOggFile(filePath, result, displayNameHint)
|
||||||
|
case ".ape", ".wv", ".mpc":
|
||||||
|
return scanAPEFile(filePath, result, displayNameHint)
|
||||||
default:
|
default:
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext != "" {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
return strings.ToLower(filepath.Ext(displayNameHint))
|
||||||
|
}
|
||||||
|
|
||||||
|
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
|
||||||
|
if displayNameHint != "" {
|
||||||
|
return displayNameHint
|
||||||
|
}
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
|
||||||
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
|
if result.TrackName == "" {
|
||||||
|
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||||
|
}
|
||||||
|
if result.ArtistName == "" {
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
if result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadMetadata(filePath)
|
metadata, err := ReadMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -230,9 +370,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
|||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
quality, err := GetAudioQuality(filePath)
|
quality, err := GetAudioQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -243,34 +388,53 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.TrackName == "" {
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadM4ATags(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata != nil {
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
if result.ReleaseDate == "" {
|
||||||
|
result.ReleaseDate = metadata.Year
|
||||||
|
}
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
}
|
||||||
|
|
||||||
quality, err := GetM4AQuality(filePath)
|
quality, err := GetM4AQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanFromFilename(filePath, result)
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadID3Tags(filePath)
|
metadata, err := ReadID3Tags(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -278,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
result.AlbumName = metadata.Album
|
result.AlbumName = metadata.Album
|
||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
@@ -286,6 +452,9 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
result.ReleaseDate = metadata.Year
|
result.ReleaseDate = metadata.Year
|
||||||
}
|
}
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
quality, err := GetMP3Quality(filePath)
|
quality, err := GetMP3Quality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -297,24 +466,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.TrackName == "" {
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadOggVorbisComments(filePath)
|
metadata, err := ReadOggVorbisComments(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -323,9 +484,14 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
result.AlbumArtist = metadata.AlbumArtist
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
result.ISRC = metadata.ISRC
|
result.ISRC = metadata.ISRC
|
||||||
result.TrackNumber = metadata.TrackNumber
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
result.DiscNumber = metadata.DiscNumber
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
result.Genre = metadata.Genre
|
result.Genre = metadata.Genre
|
||||||
result.ReleaseDate = metadata.Date
|
result.ReleaseDate = metadata.Date
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
quality, err := GetOggQuality(filePath)
|
quality, err := GetOggQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -337,21 +503,51 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.TrackName == "" {
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
tag, err := ReadAPETags(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] APE tag read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := APETagToAudioMetadata(tag)
|
||||||
|
if metadata == nil {
|
||||||
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
if metadata.Date != "" {
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
} else {
|
||||||
|
result.ReleaseDate = metadata.Year
|
||||||
|
}
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
|
||||||
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
result.MetadataFromFilename = true
|
||||||
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
|
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||||
|
|
||||||
parts := strings.SplitN(filename, " - ", 2)
|
parts := strings.SplitN(filename, " - ", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
@@ -374,7 +570,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
|
|||||||
|
|
||||||
dir := filepath.Dir(filePath)
|
dir := filepath.Dir(filePath)
|
||||||
result.AlbumName = filepath.Base(dir)
|
result.AlbumName = filepath.Base(dir)
|
||||||
if result.AlbumName == "." || result.AlbumName == "" {
|
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
|
||||||
result.AlbumName = "Unknown Album"
|
result.AlbumName = "Unknown Album"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,8 +617,22 @@ func CancelLibraryScan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ReadAudioMetadata(filePath string) (string, error) {
|
func ReadAudioMetadata(filePath string) (string, error) {
|
||||||
|
return ReadAudioMetadataWithDisplayName(filePath, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
||||||
|
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) {
|
||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
result, err := scanAudioFile(filePath, scanTime)
|
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
|
||||||
|
filePath,
|
||||||
|
displayNameHint,
|
||||||
|
coverCacheKey,
|
||||||
|
scanTime,
|
||||||
|
0,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -435,10 +645,43 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
existingFiles := make(map[string]int64)
|
||||||
// Only files that are new or have changed modification time will be scanned
|
if snapshotPath == "" {
|
||||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
return existingFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "\t", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
modTime, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingFiles[parts[1]] = modTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
|
||||||
if folderPath == "" {
|
if folderPath == "" {
|
||||||
return "{}", fmt.Errorf("folder path is empty")
|
return "{}", fmt.Errorf("folder path is empty")
|
||||||
}
|
}
|
||||||
@@ -451,22 +694,12 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
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))
|
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||||
|
|
||||||
// Reset progress
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
libraryScanProgress = LibraryScanProgress{}
|
libraryScanProgress = LibraryScanProgress{}
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
// Setup cancellation
|
|
||||||
libraryScanCancelMu.Lock()
|
libraryScanCancelMu.Lock()
|
||||||
if libraryScanCancel != nil {
|
if libraryScanCancel != nil {
|
||||||
close(libraryScanCancel)
|
close(libraryScanCancel)
|
||||||
@@ -475,69 +708,62 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
cancelCh := libraryScanCancel
|
cancelCh := libraryScanCancel
|
||||||
libraryScanCancelMu.Unlock()
|
libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
// Collect all audio files with their mod times
|
currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh)
|
||||||
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 {
|
if err != nil {
|
||||||
return "{}", err
|
return "{}", err
|
||||||
}
|
}
|
||||||
|
currentPathSet := make(map[string]bool, len(currentFiles))
|
||||||
|
for _, fileInfo := range currentFiles {
|
||||||
|
currentPathSet[fileInfo.path] = true
|
||||||
|
}
|
||||||
|
|
||||||
totalFiles := len(currentFiles)
|
totalFiles := len(currentFiles)
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
libraryScanProgress.TotalFiles = totalFiles
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
// Find files to scan (new or modified)
|
var filesToScan []libraryAudioFileInfo
|
||||||
var filesToScan []fileInfo
|
|
||||||
skippedCount := 0
|
skippedCount := 0
|
||||||
|
existingCueTrackModTimes := make(map[string]int64)
|
||||||
|
for existingPath, modTime := range existingFiles {
|
||||||
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
|
baseCuePath := existingPath[:idx]
|
||||||
|
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
|
||||||
|
existingCueTrackModTimes[baseCuePath] = modTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, f := range currentFiles {
|
for _, f := range currentFiles {
|
||||||
existingModTime, exists := existingFiles[f.path]
|
existingModTime, exists := existingFiles[f.path]
|
||||||
if !exists {
|
if !exists {
|
||||||
// New file
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
|
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||||
|
if f.modTime == cueTrackModTime {
|
||||||
|
skippedCount++
|
||||||
|
} else {
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
filesToScan = append(filesToScan, f)
|
filesToScan = append(filesToScan, f)
|
||||||
} else if f.modTime != existingModTime {
|
} else if f.modTime != existingModTime {
|
||||||
// Modified file
|
|
||||||
filesToScan = append(filesToScan, f)
|
filesToScan = append(filesToScan, f)
|
||||||
} else {
|
} else {
|
||||||
// Unchanged file - skip
|
|
||||||
skippedCount++
|
skippedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find deleted files
|
|
||||||
var deletedPaths []string
|
var deletedPaths []string
|
||||||
for existingPath := range existingFiles {
|
for existingPath := range existingFiles {
|
||||||
if !currentPathSet[existingPath] {
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
|
baseCuePath := existingPath[:idx]
|
||||||
|
if currentPathSet[baseCuePath] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
|
} else if !currentPathSet[existingPath] {
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,11 +788,29 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan the files that need scanning
|
|
||||||
results := make([]LibraryScanResult, 0, len(filesToScan))
|
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
|
cueReferencedAudioFilesInc := make(map[string]bool)
|
||||||
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
|
for _, f := range filesToScan {
|
||||||
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
if ext == ".cue" {
|
||||||
|
sheet, err := ParseCueFile(f.path)
|
||||||
|
if err == nil && sheet.FileName != "" {
|
||||||
|
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
||||||
|
if audioPath != "" {
|
||||||
|
parsedCueFiles[f.path] = scannedCueFileInfo{
|
||||||
|
sheet: sheet,
|
||||||
|
audioPath: audioPath,
|
||||||
|
}
|
||||||
|
cueReferencedAudioFilesInc[audioPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, f := range filesToScan {
|
for i, f := range filesToScan {
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
@@ -580,7 +824,38 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
result, err := scanAudioFile(f.path, scanTime)
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
|
if ext == ".cue" {
|
||||||
|
var cueResults []LibraryScanResult
|
||||||
|
cueInfo, ok := parsedCueFiles[f.path]
|
||||||
|
if ok {
|
||||||
|
cueResults, err = scanCueSheetForLibrary(
|
||||||
|
f.path,
|
||||||
|
cueInfo.sheet,
|
||||||
|
cueInfo.audioPath,
|
||||||
|
"",
|
||||||
|
f.modTime,
|
||||||
|
"",
|
||||||
|
scanTime,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, cueResults...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||||
@@ -614,3 +889,21 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||||
|
existingFiles := make(map[string]int64)
|
||||||
|
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||||
|
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||||
|
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
|
||||||
|
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestScanFromFilenameMarksMetadataFallback(t *testing.T) {
|
||||||
|
result := &LibraryScanResult{}
|
||||||
|
|
||||||
|
scanned, err := scanFromFilename(
|
||||||
|
"/proc/self/fd/209",
|
||||||
|
"189.mp3",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scanFromFilename returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !scanned.MetadataFromFilename {
|
||||||
|
t.Fatal("expected filename fallback marker to be set")
|
||||||
|
}
|
||||||
|
if scanned.TrackName != "189" {
|
||||||
|
t.Fatalf("unexpected track name: %q", scanned.TrackName)
|
||||||
|
}
|
||||||
|
if scanned.ArtistName != "Unknown Artist" {
|
||||||
|
t.Fatalf("unexpected artist name: %q", scanned.ArtistName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,6 @@ type LogBuffer struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultLogBufferSize = 500
|
defaultLogBufferSize = 500
|
||||||
maxLogMessageLength = 500
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -52,20 +51,12 @@ func GetLogBuffer() *LogBuffer {
|
|||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||||
maxSize: defaultLogBufferSize,
|
maxSize: defaultLogBufferSize,
|
||||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
loggingEnabled: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalLogBuffer
|
return globalLogBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateLogMessage(message string) string {
|
|
||||||
runes := []rune(message)
|
|
||||||
if len(runes) <= maxLogMessageLength {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
@@ -87,7 +78,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message = sanitizeSensitiveLogText(message)
|
message = sanitizeSensitiveLogText(message)
|
||||||
message = truncateLogMessage(message)
|
|
||||||
|
|
||||||
entry := LogEntry{
|
entry := LogEntry{
|
||||||
Timestamp: time.Now().Format("15:04:05.000"),
|
Timestamp: time.Now().Format("15:04:05.000"),
|
||||||
@@ -155,13 +145,10 @@ func LogError(tag, format string, args ...interface{}) {
|
|||||||
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
|
||||||
// It parses the tag from the format string if it starts with [Tag]
|
|
||||||
func GoLog(format string, args ...interface{}) {
|
func GoLog(format string, args ...interface{}) {
|
||||||
message := fmt.Sprintf(format, args...)
|
message := fmt.Sprintf(format, args...)
|
||||||
message = strings.TrimSuffix(message, "\n")
|
message = strings.TrimSuffix(message, "\n")
|
||||||
|
|
||||||
// Extract tag from message if present (e.g., "[Tidal] message")
|
|
||||||
tag := "Go"
|
tag := "Go"
|
||||||
level := "INFO"
|
level := "INFO"
|
||||||
|
|
||||||
@@ -173,7 +160,6 @@ func GoLog(format string, args ...interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine level from message content
|
|
||||||
msgLower := strings.ToLower(message)
|
msgLower := strings.ToLower(message)
|
||||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
||||||
level = "ERROR"
|
level = "ERROR"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ const (
|
|||||||
durationToleranceSec = 10.0
|
durationToleranceSec = 10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
// Lyrics provider names (used in settings and cascade ordering)
|
|
||||||
const (
|
const (
|
||||||
LyricsProviderLRCLIB = "lrclib"
|
LyricsProviderLRCLIB = "lrclib"
|
||||||
LyricsProviderNetease = "netease"
|
LyricsProviderNetease = "netease"
|
||||||
@@ -29,8 +28,6 @@ const (
|
|||||||
LyricsProviderQQMusic = "qqmusic"
|
LyricsProviderQQMusic = "qqmusic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
|
|
||||||
// LRCLIB first (no proxy dependency), then the others.
|
|
||||||
var DefaultLyricsProviders = []string{
|
var DefaultLyricsProviders = []string{
|
||||||
LyricsProviderLRCLIB,
|
LyricsProviderLRCLIB,
|
||||||
LyricsProviderMusixmatch,
|
LyricsProviderMusixmatch,
|
||||||
@@ -39,13 +36,11 @@ var DefaultLyricsProviders = []string{
|
|||||||
LyricsProviderQQMusic,
|
LyricsProviderQQMusic,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global lyrics provider configuration
|
|
||||||
var (
|
var (
|
||||||
lyricsProvidersMu sync.RWMutex
|
lyricsProvidersMu sync.RWMutex
|
||||||
lyricsProviders []string // ordered list of enabled providers
|
lyricsProviders []string // ordered list of enabled providers
|
||||||
)
|
)
|
||||||
|
|
||||||
// LyricsFetchOptions controls optional provider-specific enhancements.
|
|
||||||
type LyricsFetchOptions struct {
|
type LyricsFetchOptions struct {
|
||||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||||
@@ -65,8 +60,6 @@ var (
|
|||||||
lyricsFetchOptions = defaultLyricsFetchOptions
|
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
|
|
||||||
// Providers not in the list are disabled. An empty list resets to defaults.
|
|
||||||
func SetLyricsProviderOrder(providers []string) {
|
func SetLyricsProviderOrder(providers []string) {
|
||||||
lyricsProvidersMu.Lock()
|
lyricsProvidersMu.Lock()
|
||||||
defer lyricsProvidersMu.Unlock()
|
defer lyricsProvidersMu.Unlock()
|
||||||
@@ -76,7 +69,6 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate provider names
|
|
||||||
validNames := map[string]bool{
|
validNames := map[string]bool{
|
||||||
LyricsProviderLRCLIB: true,
|
LyricsProviderLRCLIB: true,
|
||||||
LyricsProviderNetease: true,
|
LyricsProviderNetease: true,
|
||||||
@@ -97,7 +89,6 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
|
||||||
func GetLyricsProviderOrder() []string {
|
func GetLyricsProviderOrder() []string {
|
||||||
lyricsProvidersMu.RLock()
|
lyricsProvidersMu.RLock()
|
||||||
defer lyricsProvidersMu.RUnlock()
|
defer lyricsProvidersMu.RUnlock()
|
||||||
@@ -111,14 +102,13 @@ func GetLyricsProviderOrder() []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
|
||||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||||
return []map[string]interface{}{
|
return []map[string]interface{}{
|
||||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +121,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
|
||||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||||
normalized := normalizeLyricsFetchOptions(opts)
|
normalized := normalizeLyricsFetchOptions(opts)
|
||||||
|
|
||||||
@@ -147,7 +136,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
|
||||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||||
lyricsFetchOptionsMu.RLock()
|
lyricsFetchOptionsMu.RLock()
|
||||||
defer lyricsFetchOptionsMu.RUnlock()
|
defer lyricsFetchOptionsMu.RUnlock()
|
||||||
@@ -376,6 +364,18 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
|
|||||||
return bestPlain
|
return bestPlain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func plainLyricsFromTimedLines(lines []LyricsLine) string {
|
||||||
|
parts := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
words := strings.TrimSpace(line.Words)
|
||||||
|
if words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, words)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||||
diff := math.Abs(lrcDuration - targetDuration)
|
diff := math.Abs(lrcDuration - targetDuration)
|
||||||
return diff <= durationToleranceSec
|
return diff <= durationToleranceSec
|
||||||
@@ -385,8 +385,8 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
primaryArtist := normalizeArtistName(artistName)
|
primaryArtist := normalizeArtistName(artistName)
|
||||||
fetchOptions := GetLyricsFetchOptions()
|
fetchOptions := GetLyricsFetchOptions()
|
||||||
|
|
||||||
extManager := GetExtensionManager()
|
extManager := getExtensionManager()
|
||||||
var extensionProviders []*ExtensionProviderWrapper
|
var extensionProviders []*extensionProviderWrapper
|
||||||
if extManager != nil {
|
if extManager != nil {
|
||||||
extensionProviders = extManager.GetLyricsProviders()
|
extensionProviders = extManager.GetLyricsProviders()
|
||||||
}
|
}
|
||||||
@@ -411,7 +411,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return lyricsHasUsableText(l)
|
return lyricsHasUsableText(l)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try extension lyrics providers first
|
|
||||||
if len(extensionProviders) > 0 {
|
if len(extensionProviders) > 0 {
|
||||||
for _, provider := range extensionProviders {
|
for _, provider := range extensionProviders {
|
||||||
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||||
@@ -434,13 +433,11 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return &cachedCopy, nil
|
return &cachedCopy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get configured provider order
|
|
||||||
providerOrder := GetLyricsProviderOrder()
|
providerOrder := GetLyricsProviderOrder()
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
|
|
||||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||||
|
|
||||||
// Cascade through all configured built-in providers
|
|
||||||
for _, providerName := range providerOrder {
|
for _, providerName := range providerOrder {
|
||||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||||
|
|
||||||
@@ -529,19 +526,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return nil, fmt.Errorf("lyrics not found from any source")
|
return nil, fmt.Errorf("lyrics not found from any source")
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
|
|
||||||
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
||||||
var lyrics *LyricsResponse
|
var lyrics *LyricsResponse
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// 1. Exact match with primary artist
|
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Exact match with full artist name
|
|
||||||
if primaryArtist != artistName {
|
if primaryArtist != artistName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
@@ -550,7 +544,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Simplified track name
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
@@ -559,7 +552,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Search by query
|
|
||||||
query := primaryArtist + " " + trackName
|
query := primaryArtist + " " + trackName
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
|
||||||
@@ -567,7 +559,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Search with simplified track name
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = primaryArtist + " " + simplifiedTrack
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
@@ -651,6 +642,22 @@ func parseSyncedLyrics(syncedLyrics string) []LyricsLine {
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func plainTextLyricsLines(rawLyrics string) []LyricsLine {
|
||||||
|
var lines []LyricsLine
|
||||||
|
for _, line := range strings.Split(rawLyrics, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, LyricsLine{
|
||||||
|
StartTimeMs: 0,
|
||||||
|
Words: trimmed,
|
||||||
|
EndTimeMs: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func lyricsHasUsableText(lyrics *LyricsResponse) bool {
|
func lyricsHasUsableText(lyrics *LyricsResponse) bool {
|
||||||
if lyrics == nil {
|
if lyrics == nil {
|
||||||
return false
|
return false
|
||||||
@@ -669,8 +676,6 @@ func lyricsHasUsableText(lyrics *LyricsResponse) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectLyricsErrorPayload extracts human-readable error messages from
|
|
||||||
// JSON payloads returned by lyrics proxies when no lyric is available.
|
|
||||||
func detectLyricsErrorPayload(raw string) (string, bool) {
|
func detectLyricsErrorPayload(raw string) (string, bool) {
|
||||||
trimmed := strings.TrimSpace(raw)
|
trimmed := strings.TrimSpace(raw)
|
||||||
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
|
||||||
@@ -742,7 +747,7 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
|||||||
|
|
||||||
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:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
|
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
@@ -790,8 +795,16 @@ func simplifyTrackName(name string) string {
|
|||||||
re := regexp.MustCompile("(?i)" + pattern)
|
re := regexp.MustCompile("(?i)" + pattern)
|
||||||
result = re.ReplaceAllString(result, "")
|
result = re.ReplaceAllString(result, "")
|
||||||
}
|
}
|
||||||
|
result = strings.TrimSpace(result)
|
||||||
|
if result == "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(result)
|
if loose := normalizeLooseTitle(result); loose != "" {
|
||||||
|
return loose
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeArtistName(name string) string {
|
func normalizeArtistName(name string) string {
|
||||||
|
|||||||
@@ -4,125 +4,25 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppleMusicClient fetches lyrics from Apple Music.
|
|
||||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
|
||||||
type AppleMusicClient struct {
|
type AppleMusicClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Music token manager — singleton with mutex for thread safety
|
type appleMusicSearchResult struct {
|
||||||
type appleTokenManager struct {
|
ID string `json:"id"`
|
||||||
mu sync.Mutex
|
SongName string `json:"songName"`
|
||||||
token string
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var globalAppleTokenManager = &appleTokenManager{}
|
|
||||||
|
|
||||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.token != "" {
|
|
||||||
return m.token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Fetch the Apple Music beta page
|
|
||||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Find the index JS file URL
|
|
||||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
|
||||||
match := indexJsRegex.Find(body)
|
|
||||||
if match == nil {
|
|
||||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
|
||||||
}
|
|
||||||
|
|
||||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
|
||||||
|
|
||||||
// Step 3: Fetch the JS file
|
|
||||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
|
||||||
}
|
|
||||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
jsResp, err := client.Do(jsReq)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
|
||||||
}
|
|
||||||
defer jsResp.Body.Close()
|
|
||||||
|
|
||||||
jsBody, err := io.ReadAll(jsResp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Extract JWT token (starts with eyJh)
|
|
||||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
|
||||||
tokenMatch := tokenRegex.Find(jsBody)
|
|
||||||
if tokenMatch == nil {
|
|
||||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
|
||||||
}
|
|
||||||
|
|
||||||
m.token = string(tokenMatch)
|
|
||||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
|
||||||
return m.token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *appleTokenManager) clearToken() {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.token = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apple Music API response models
|
|
||||||
type appleMusicSearchResponse struct {
|
|
||||||
Results struct {
|
|
||||||
Songs *struct {
|
|
||||||
Data []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
} `json:"data"`
|
|
||||||
} `json:"songs"`
|
|
||||||
} `json:"results"`
|
|
||||||
Resources *struct {
|
|
||||||
Songs map[string]struct {
|
|
||||||
Attributes struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ArtistName string `json:"artistName"`
|
|
||||||
AlbumName string `json:"albumName"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Artwork struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"artwork"`
|
|
||||||
} `json:"attributes"`
|
|
||||||
} `json:"songs"`
|
|
||||||
} `json:"resources"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
|
||||||
type paxResponse struct {
|
type paxResponse struct {
|
||||||
Type string `json:"type"` // "Syllable" or "Line"
|
Type string `json:"type"` // "Syllable" or "Line"
|
||||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||||
@@ -150,32 +50,70 @@ func NewAppleMusicClient() *AppleMusicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
|
||||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
if len(results) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||||
|
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||||
|
if normalizedArtist == "" {
|
||||||
|
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
|
||||||
|
}
|
||||||
|
|
||||||
|
bestIndex := 0
|
||||||
|
bestScore := -1
|
||||||
|
for i := range results {
|
||||||
|
result := &results[i]
|
||||||
|
score := 0
|
||||||
|
|
||||||
|
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
|
||||||
|
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case candidateTrack == normalizedTrack:
|
||||||
|
score += 50
|
||||||
|
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||||
|
score += 25
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case candidateArtist == normalizedArtist:
|
||||||
|
score += 60
|
||||||
|
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||||
|
score += 30
|
||||||
|
}
|
||||||
|
|
||||||
|
if durationSec > 0 && result.Duration > 0 {
|
||||||
|
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
|
||||||
|
if diff <= durationToleranceSec {
|
||||||
|
score += 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &results[bestIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||||
query := trackName + " " + artistName
|
query := trackName + " " + artistName
|
||||||
if strings.TrimSpace(query) == "" {
|
if strings.TrimSpace(query) == "" {
|
||||||
return "", fmt.Errorf("empty search query")
|
return "", fmt.Errorf("empty search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("apple music token error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encodedQuery := url.QueryEscape(query)
|
encodedQuery := url.QueryEscape(query)
|
||||||
searchURL := fmt.Sprintf(
|
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||||
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
|
||||||
encodedQuery,
|
|
||||||
)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
req, err := http.NewRequest("GET", searchURL, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
req.Header.Set("Origin", "https://music.apple.com")
|
|
||||||
req.Header.Set("Referer", "https://music.apple.com/")
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
@@ -185,28 +123,23 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode == 401 {
|
|
||||||
globalAppleTokenManager.clearToken()
|
|
||||||
return "", fmt.Errorf("apple music token expired")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp appleMusicSearchResponse
|
var searchResp []appleMusicSearchResult
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||||
|
if best == nil || strings.TrimSpace(best.ID) == "" {
|
||||||
return "", fmt.Errorf("no songs found on apple music")
|
return "", fmt.Errorf("no songs found on apple music")
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchResp.Results.Songs.Data[0].ID, nil
|
return strings.TrimSpace(best.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
|
||||||
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||||
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
|
||||||
|
|
||||||
@@ -239,15 +172,12 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
|||||||
return bodyStr, nil
|
return bodyStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format.
|
|
||||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||||
// Try to parse as PaxResponse first
|
|
||||||
var paxResp paxResponse
|
var paxResp paxResponse
|
||||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as a direct list of PaxLyrics
|
|
||||||
var directLyrics []paxLyrics
|
var directLyrics []paxLyrics
|
||||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||||
@@ -317,14 +247,13 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
|
|||||||
return strings.TrimSpace(sb.String())
|
return strings.TrimSpace(sb.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
|
|
||||||
func (c *AppleMusicClient) FetchLyrics(
|
func (c *AppleMusicClient) FetchLyrics(
|
||||||
trackName,
|
trackName,
|
||||||
artistName string,
|
artistName string,
|
||||||
durationSec float64,
|
durationSec float64,
|
||||||
multiPersonWordByWord bool,
|
multiPersonWordByWord bool,
|
||||||
) (*LyricsResponse, error) {
|
) (*LyricsResponse, error) {
|
||||||
songID, err := c.SearchSong(trackName, artistName)
|
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -337,10 +266,8 @@ func (c *AppleMusicClient) FetchLyrics(
|
|||||||
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as pax format (word-by-word or line)
|
|
||||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If pax parsing fails, try to parse as direct LRC text
|
|
||||||
lrcText = rawLyrics
|
lrcText = rawLyrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,19 +281,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to plain text if no timestamps found
|
resultLines := plainTextLyricsLines(lrcText)
|
||||||
plainLines := strings.Split(lrcText, "\n")
|
|
||||||
var resultLines []LyricsLine
|
|
||||||
for _, line := range plainLines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if trimmed != "" {
|
|
||||||
resultLines = append(resultLines, LyricsLine{
|
|
||||||
StartTimeMs: 0,
|
|
||||||
Words: trimmed,
|
|
||||||
EndTimeMs: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resultLines) > 0 {
|
if len(resultLines) > 0 {
|
||||||
return &LyricsResponse{
|
return &LyricsResponse{
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
|
|
||||||
// The proxy handles Musixmatch authentication internally.
|
|
||||||
type MusixmatchClient struct {
|
type MusixmatchClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Musixmatch proxy response models
|
|
||||||
type musixmatchSearchResponse struct {
|
type musixmatchSearchResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
SongName string `json:"songName"`
|
SongName string `json:"songName"`
|
||||||
@@ -46,168 +45,143 @@ type musixmatchLyricsResponse struct {
|
|||||||
func NewMusixmatchClient() *MusixmatchClient {
|
func NewMusixmatchClient() *MusixmatchClient {
|
||||||
return &MusixmatchClient{
|
return &MusixmatchClient{
|
||||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
baseURL: "http://158.180.60.95",
|
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
|
||||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
|
||||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
|
||||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||||
return nil, fmt.Errorf("empty track or artist name")
|
return "", fmt.Errorf("empty track or artist name")
|
||||||
}
|
}
|
||||||
|
|
||||||
encodedArtist := url.QueryEscape(artistName)
|
params := url.Values{}
|
||||||
encodedTrack := url.QueryEscape(trackName)
|
params.Set("t", trackName)
|
||||||
|
params.Set("a", artistName)
|
||||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
params.Set("type", lyricsType)
|
||||||
|
params.Set("format", "lrc")
|
||||||
|
if durationSec > 0 {
|
||||||
|
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(language) != "" {
|
||||||
|
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
|
||||||
|
}
|
||||||
|
fullURL := c.baseURL + "?" + params.Encode()
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", fullURL, nil)
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
return "", fmt.Errorf("musixmatch request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
trimmed := strings.TrimSpace(string(body))
|
||||||
|
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||||
|
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result musixmatchSearchResponse
|
var lrcPayload string
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||||
|
if lrcPayload == "" {
|
||||||
|
return "", fmt.Errorf("empty musixmatch lyrics payload")
|
||||||
|
}
|
||||||
|
return lrcPayload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &result, nil
|
trimmed := strings.TrimSpace(string(body))
|
||||||
|
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||||
|
return "", fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("failed to decode musixmatch response")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
||||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
|
||||||
lang := strings.ToLower(strings.TrimSpace(language))
|
lang := strings.ToLower(strings.TrimSpace(language))
|
||||||
if songID <= 0 || lang == "" {
|
if lang == "" {
|
||||||
return nil, fmt.Errorf("invalid song id or language")
|
return nil, fmt.Errorf("invalid language")
|
||||||
}
|
}
|
||||||
|
|
||||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", fullURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result musixmatchSearchResponse
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if len(lines) > 0 {
|
||||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer synced lyrics for selected language
|
plainLines := plainTextLyricsLines(lrcText)
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
if len(plainLines) > 0 {
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
return &LyricsResponse{
|
||||||
if len(lines) > 0 {
|
Lines: plainLines,
|
||||||
return &LyricsResponse{
|
SyncType: "UNSYNCED",
|
||||||
Lines: lines,
|
PlainLyrics: lrcText,
|
||||||
SyncType: "LINE_SYNCED",
|
Provider: "Musixmatch",
|
||||||
Provider: "Musixmatch",
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
}, nil
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to unsynced lyrics for selected language
|
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
|
||||||
var lines []LyricsLine
|
|
||||||
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if trimmed != "" {
|
|
||||||
lines = append(lines, LyricsLine{
|
|
||||||
StartTimeMs: 0,
|
|
||||||
Words: trimmed,
|
|
||||||
EndTimeMs: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(lines) > 0 {
|
|
||||||
return &LyricsResponse{
|
|
||||||
Lines: lines,
|
|
||||||
SyncType: "UNSYNCED",
|
|
||||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
|
||||||
Provider: "Musixmatch",
|
|
||||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
|
||||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||||
if err != nil {
|
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
|
||||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
|
||||||
if localizedErr == nil {
|
if localizedErr == nil {
|
||||||
return localized, nil
|
return localized, nil
|
||||||
}
|
}
|
||||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer synced lyrics
|
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
if err != nil {
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
return nil, err
|
||||||
if len(lines) > 0 {
|
|
||||||
return &LyricsResponse{
|
|
||||||
Lines: lines,
|
|
||||||
SyncType: "LINE_SYNCED",
|
|
||||||
Provider: "Musixmatch",
|
|
||||||
Source: "Musixmatch",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to unsynced lyrics
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
if len(lines) > 0 {
|
||||||
var lines []LyricsLine
|
return &LyricsResponse{
|
||||||
for _, line := range strings.Split(result.UnsyncedLyrics.Lyrics, "\n") {
|
Lines: lines,
|
||||||
trimmed := strings.TrimSpace(line)
|
SyncType: "LINE_SYNCED",
|
||||||
if trimmed != "" {
|
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||||
lines = append(lines, LyricsLine{
|
Provider: "Musixmatch",
|
||||||
StartTimeMs: 0,
|
Source: "Musixmatch",
|
||||||
Words: trimmed,
|
}, nil
|
||||||
EndTimeMs: 0,
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(lines) > 0 {
|
plainLines := plainTextLyricsLines(lrcText)
|
||||||
return &LyricsResponse{
|
if len(plainLines) > 0 {
|
||||||
Lines: lines,
|
return &LyricsResponse{
|
||||||
SyncType: "UNSYNCED",
|
Lines: plainLines,
|
||||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
SyncType: "UNSYNCED",
|
||||||
Provider: "Musixmatch",
|
PlainLyrics: lrcText,
|
||||||
Source: "Musixmatch",
|
Provider: "Musixmatch",
|
||||||
}, nil
|
Source: "Musixmatch",
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||||
|
|||||||
@@ -9,13 +9,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
|
||||||
// This is a direct public API — no proxy dependency.
|
|
||||||
type NeteaseClient struct {
|
type NeteaseClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Netease API response models
|
|
||||||
type neteaseSearchResponse struct {
|
type neteaseSearchResponse struct {
|
||||||
Result struct {
|
Result struct {
|
||||||
Songs []struct {
|
Songs []struct {
|
||||||
@@ -53,19 +50,15 @@ func NewNeteaseClient() *NeteaseClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchSong searches for a song on Netease and returns the song ID.
|
|
||||||
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
|
||||||
query := trackName + " " + artistName
|
query := trackName + " " + artistName
|
||||||
if strings.TrimSpace(query) == "" {
|
if strings.TrimSpace(query) == "" {
|
||||||
return 0, fmt.Errorf("empty search query")
|
return 0, fmt.Errorf("empty search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
searchURL := "http://music.163.com/api/search/pc"
|
searchURL := "https://lyrics.paxsenix.org/netease/search"
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("s", query)
|
params.Set("q", query)
|
||||||
params.Set("type", "1")
|
|
||||||
params.Set("limit", "1")
|
|
||||||
params.Set("offset", "0")
|
|
||||||
|
|
||||||
fullURL := searchURL + "?" + params.Encode()
|
fullURL := searchURL + "?" + params.Encode()
|
||||||
|
|
||||||
@@ -101,14 +94,10 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
|||||||
return searchResp.Result.Songs[0].ID, nil
|
return searchResp.Result.Songs[0].ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
|
||||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||||
lyricsURL := "http://music.163.com/api/song/lyric"
|
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("id", fmt.Sprintf("%d", songID))
|
params.Set("id", fmt.Sprintf("%d", songID))
|
||||||
params.Set("lv", "1")
|
|
||||||
params.Set("tv", "1")
|
|
||||||
params.Set("rv", "1")
|
|
||||||
|
|
||||||
fullURL := lyricsURL + "?" + params.Encode()
|
fullURL := lyricsURL + "?" + params.Encode()
|
||||||
|
|
||||||
@@ -154,7 +143,6 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
|
|||||||
return lyric, nil
|
return lyric, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics searches for a track and returns parsed LyricsResponse.
|
|
||||||
func (c *NeteaseClient) FetchLyrics(
|
func (c *NeteaseClient) FetchLyrics(
|
||||||
trackName,
|
trackName,
|
||||||
artistName string,
|
artistName string,
|
||||||
@@ -172,10 +160,8 @@ func (c *NeteaseClient) FetchLyrics(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the LRC text into LyricsResponse
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
// May be plain text lyrics without timestamps
|
|
||||||
plainLines := strings.Split(lrcText, "\n")
|
plainLines := strings.Split(lrcText, "\n")
|
||||||
for _, line := range plainLines {
|
for _, line := range plainLines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|||||||
@@ -1,46 +1,29 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QQMusicClient fetches lyrics from QQ Music.
|
|
||||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
|
||||||
type QQMusicClient struct {
|
type QQMusicClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQ Music search response models
|
type qqLyricsMetadataRequest struct {
|
||||||
type qqMusicSearchResponse struct {
|
Artist []string `json:"artist"`
|
||||||
Data struct {
|
Album string `json:"album,omitempty"`
|
||||||
Song struct {
|
SongID int64 `json:"songid,omitempty"`
|
||||||
List []struct {
|
Title string `json:"title"`
|
||||||
Title string `json:"title"`
|
Duration int64 `json:"duration,omitempty"`
|
||||||
Singer []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"singer"`
|
|
||||||
Album struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"album"`
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
} `json:"list"`
|
|
||||||
} `json:"song"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQ Music lyrics request payload for paxsenix proxy
|
type qqLyricsMetadataResponse struct {
|
||||||
type qqLyricsPayload struct {
|
Lyrics []paxLyrics `json:"lyrics"`
|
||||||
Artist []string `json:"artist"`
|
|
||||||
Album string `json:"album"`
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQMusicClient() *QQMusicClient {
|
func NewQQMusicClient() *QQMusicClient {
|
||||||
@@ -49,79 +32,28 @@ func NewQQMusicClient() *QQMusicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
payload := qqLyricsMetadataRequest{
|
||||||
query := trackName + " " + artistName
|
Artist: []string{artistName},
|
||||||
if strings.TrimSpace(query) == "" {
|
Title: trackName,
|
||||||
return nil, fmt.Errorf("empty search query")
|
}
|
||||||
|
if durationSec > 0 {
|
||||||
|
payload.Duration = int64(math.Round(durationSec))
|
||||||
}
|
}
|
||||||
|
|
||||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
|
||||||
params := url.Values{}
|
|
||||||
params.Set("format", "json")
|
|
||||||
params.Set("inCharset", "utf8")
|
|
||||||
params.Set("outCharset", "utf8")
|
|
||||||
params.Set("platform", "yqq.json")
|
|
||||||
params.Set("new_json", "1")
|
|
||||||
params.Set("w", query)
|
|
||||||
|
|
||||||
fullURL := searchURL + "?" + params.Encode()
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", fullURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchResp qqMusicSearchResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(searchResp.Data.Song.List) == 0 {
|
|
||||||
return nil, fmt.Errorf("no songs found on qqmusic")
|
|
||||||
}
|
|
||||||
|
|
||||||
song := searchResp.Data.Song.List[0]
|
|
||||||
|
|
||||||
var artists []string
|
|
||||||
for _, singer := range song.Singer {
|
|
||||||
artists = append(artists, singer.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &qqLyricsPayload{
|
|
||||||
Artist: artists,
|
|
||||||
Album: song.Album.Name,
|
|
||||||
ID: song.ID,
|
|
||||||
Title: song.Title,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
|
||||||
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
|
||||||
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
|
||||||
|
|
||||||
payloadBytes, err := json.Marshal(payload)
|
payloadBytes, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
@@ -147,19 +79,24 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
|
|||||||
return bodyStr, nil
|
return bodyStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||||
|
var response qqLyricsMetadataResponse
|
||||||
|
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
|
||||||
|
}
|
||||||
|
if len(response.Lyrics) == 0 {
|
||||||
|
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||||
|
}
|
||||||
|
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *QQMusicClient) FetchLyrics(
|
func (c *QQMusicClient) FetchLyrics(
|
||||||
trackName,
|
trackName,
|
||||||
artistName string,
|
artistName string,
|
||||||
durationSec float64,
|
durationSec float64,
|
||||||
multiPersonWordByWord bool,
|
multiPersonWordByWord bool,
|
||||||
) (*LyricsResponse, error) {
|
) (*LyricsResponse, error) {
|
||||||
payload, err := c.searchSong(trackName, artistName)
|
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,11 +104,13 @@ func (c *QQMusicClient) FetchLyrics(
|
|||||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as pax format (word-by-word or line)
|
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If pax parsing fails, try to use as direct LRC text
|
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||||
lrcText = rawLyrics
|
lrcText = fallback
|
||||||
|
} else {
|
||||||
|
lrcText = rawLyrics
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
lines := parseSyncedLyrics(lrcText)
|
||||||
@@ -184,19 +123,7 @@ func (c *QQMusicClient) FetchLyrics(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to plain text
|
resultLines := plainTextLyricsLines(lrcText)
|
||||||
plainLines := strings.Split(lrcText, "\n")
|
|
||||||
var resultLines []LyricsLine
|
|
||||||
for _, line := range plainLines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if trimmed != "" {
|
|
||||||
resultLines = append(resultLines, LyricsLine{
|
|
||||||
StartTimeMs: 0,
|
|
||||||
Words: trimmed,
|
|
||||||
EndTimeMs: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resultLines) > 0 {
|
if len(resultLines) > 0 {
|
||||||
return &LyricsResponse{
|
return &LyricsResponse{
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-flac/flacvorbis/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitArtistTagValues(t *testing.T) {
|
||||||
|
got := splitArtistTagValues("Artist A, Artist B feat. Artist C & Artist B")
|
||||||
|
want := []string{"Artist A", "Artist B", "Artist C"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("splitArtistTagValues() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetArtistCommentsSplitVorbis(t *testing.T) {
|
||||||
|
cmt := flacvorbis.New()
|
||||||
|
setArtistComments(cmt, "ARTIST", "Artist A, Artist B", artistTagModeSplitVorbis)
|
||||||
|
|
||||||
|
got := getCommentValues(cmt, "ARTIST")
|
||||||
|
want := []string{"Artist A", "Artist B"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("getCommentValues(ARTIST) = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVorbisCommentsJoinsRepeatedArtists(t *testing.T) {
|
||||||
|
metadata := &AudioMetadata{}
|
||||||
|
parseVorbisComments(
|
||||||
|
buildVorbisCommentPayload(
|
||||||
|
[]string{
|
||||||
|
"TITLE=Song",
|
||||||
|
"ARTIST=Artist A",
|
||||||
|
"ARTIST=Artist B",
|
||||||
|
"ALBUMARTIST=Album Artist A",
|
||||||
|
"ALBUMARTIST=Album Artist B",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if metadata.Title != "Song" {
|
||||||
|
t.Fatalf("title = %q", metadata.Title)
|
||||||
|
}
|
||||||
|
if metadata.Artist != "Artist A, Artist B" {
|
||||||
|
t.Fatalf("artist = %q", metadata.Artist)
|
||||||
|
}
|
||||||
|
if metadata.AlbumArtist != "Album Artist A, Album Artist B" {
|
||||||
|
t.Fatalf("album artist = %q", metadata.AlbumArtist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVorbisCommentPayload(comments []string) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = binary.Write(&buf, binary.LittleEndian, uint32(len("spotiflac")))
|
||||||
|
buf.WriteString("spotiflac")
|
||||||
|
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comments)))
|
||||||
|
for _, comment := range comments {
|
||||||
|
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comment)))
|
||||||
|
buf.WriteString(comment)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
data interface{}
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cacheEntry) isExpired() bool {
|
||||||
|
return time.Now().After(e.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackMetadata struct {
|
||||||
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AlbumName string `json:"album_name"`
|
||||||
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
|
ExternalURL string `json:"external_urls"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumTrackMetadata struct {
|
||||||
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AlbumName string `json:"album_name"`
|
||||||
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
TotalTracks int `json:"total_tracks,omitempty"`
|
||||||
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
|
ExternalURL string `json:"external_urls"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
|
Composer string `json:"composer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumInfoMetadata struct {
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
ArtistId string `json:"artist_id,omitempty"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Copyright string `json:"copyright,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumResponsePayload struct {
|
||||||
|
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
||||||
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaylistInfoMetadata struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Images string `json:"images,omitempty"`
|
||||||
|
Tracks struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
Owner struct {
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
} `json:"owner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaylistResponsePayload struct {
|
||||||
|
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
||||||
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistInfoMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistAlbumMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistResponsePayload struct {
|
||||||
|
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||||
|
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackResponse struct {
|
||||||
|
Track TrackMetadata `json:"track"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchArtistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchAlbumResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchPlaylistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchAllResult struct {
|
||||||
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
|
Artists []SearchArtistResult `json:"artists"`
|
||||||
|
Albums []SearchAlbumResult `json:"albums"`
|
||||||
|
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
// mobile_deps.go
|
|
||||||
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
// 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.
|
// These packages are required by gomobile bind but not directly imported in code.
|
||||||
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
// Required for gomobile bind to work
|
|
||||||
_ "golang.org/x/mobile/bind"
|
_ "golang.org/x/mobile/bind"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,11 +12,68 @@ func isFDOutput(outputFD int) bool {
|
|||||||
|
|
||||||
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
||||||
if isFDOutput(outputFD) {
|
if isFDOutput(outputFD) {
|
||||||
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
|
// Never hand the original detached FD directly to a provider attempt.
|
||||||
|
// Fallback chains may retry with another provider after a failure.
|
||||||
|
// If the first attempt closes the original FD, its numeric ID can be
|
||||||
|
// reused by unrelated resources and a later close may trigger fdsan abort.
|
||||||
|
dupFD, err := dupOutputFD(outputFD)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to duplicate output fd %d: %w", outputFD, err)
|
||||||
|
}
|
||||||
|
if err := prepareDupFDForWrite(dupFD, outputFD); err != nil {
|
||||||
|
_ = closeFD(dupFD)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return os.NewFile(uintptr(dupFD), fmt.Sprintf("saf_fd_%d_dup_%d", outputFD, dupFD)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path := strings.TrimSpace(outputPath)
|
||||||
|
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||||
|
// Re-open procfs fd path instead of taking ownership of raw detached fd.
|
||||||
|
// Some SAF providers reject O_TRUNC on these descriptors with EACCES/EPERM.
|
||||||
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0)
|
||||||
|
if err == nil {
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
|
||||||
|
return os.OpenFile(path, os.O_WRONLY, 0)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return os.Create(outputPath)
|
return os.Create(outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prepareDupFDForWrite(dupFD, originalFD int) error {
|
||||||
|
// Best-effort reset so retries start writing from byte 0.
|
||||||
|
if err := truncateFD(dupFD); err != nil {
|
||||||
|
if isBestEffortTruncateError(err) {
|
||||||
|
GoLog("[OutputFD] truncate not supported on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("failed to truncate output fd %d (dup of %d): %w", dupFD, originalFD, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := seekFDStart(dupFD); err != nil {
|
||||||
|
GoLog("[OutputFD] seek reset failed on fd %d (dup of %d): %v\n", dupFD, originalFD, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeOwnedOutputFD(outputFD int) {
|
||||||
|
if !isFDOutput(outputFD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := closeFD(outputFD); err != nil {
|
||||||
|
if !isBadFD(err) {
|
||||||
|
GoLog("[OutputFD] failed to close detached fd %d: %v\n", outputFD, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[OutputFD] closed detached fd %d\n", outputFD)
|
||||||
|
}
|
||||||
|
|
||||||
func cleanupOutputOnError(outputPath string, outputFD int) {
|
func cleanupOutputOnError(outputPath string, outputFD int) {
|
||||||
if isFDOutput(outputFD) {
|
if isFDOutput(outputFD) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "syscall"
|
||||||
|
|
||||||
|
func dupOutputFD(fd int) (int, error) {
|
||||||
|
return syscall.Dup(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateFD(fd int) error {
|
||||||
|
return syscall.Ftruncate(fd, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seekFDStart(fd int) error {
|
||||||
|
_, err := syscall.Seek(fd, 0, 0)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeFD(fd int) error {
|
||||||
|
return syscall.Close(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBestEffortTruncateError(err error) bool {
|
||||||
|
switch err {
|
||||||
|
case syscall.EPERM, syscall.EACCES, syscall.EINVAL, syscall.ESPIPE, syscall.ENOSYS:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBadFD(err error) bool {
|
||||||
|
return err == syscall.EBADF
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
func dupOutputFD(fd int) (int, error) {
|
||||||
|
// Windows build is primarily for local tooling/tests.
|
||||||
|
// Android runtime uses the !windows implementation.
|
||||||
|
return fd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateFD(fd int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seekFDStart(fd int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeFD(fd int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBestEffortTruncateError(err error) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBadFD(err error) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
type TrackIDCacheEntry struct {
|
type TrackIDCacheEntry struct {
|
||||||
TidalTrackID int64
|
TidalTrackID int64
|
||||||
QobuzTrackID int64
|
QobuzTrackID int64
|
||||||
AmazonURL string
|
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,25 +106,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
entry, exists := c.cache[isrc]
|
|
||||||
if !exists {
|
|
||||||
entry = &TrackIDCacheEntry{}
|
|
||||||
c.cache[isrc] = entry
|
|
||||||
}
|
|
||||||
entry.AmazonURL = amazonURL
|
|
||||||
now := time.Now()
|
|
||||||
entry.ExpiresAt = now.Add(c.ttl)
|
|
||||||
|
|
||||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
|
||||||
c.pruneExpiredLocked(now)
|
|
||||||
c.lastCleanup = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TrackIDCache) Clear() {
|
func (c *TrackIDCache) Clear() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -235,8 +215,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||||
case "amazon":
|
|
||||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
|
||||||
}
|
}
|
||||||
}(req)
|
}(req)
|
||||||
}
|
}
|
||||||
@@ -256,12 +234,10 @@ func preWarmTidalCache(isrc, _, _ string) {
|
|||||||
// 1. From SongLink (fast, no Qobuz API call needed)
|
// 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)
|
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||||
func preWarmQobuzCache(isrc, spotifyID string) {
|
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||||
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
|
||||||
if spotifyID != "" {
|
if spotifyID != "" {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||||
// Parse QobuzID to int64
|
|
||||||
var trackID int64
|
var trackID int64
|
||||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
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)
|
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||||
@@ -271,7 +247,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -280,14 +255,6 @@ func preWarmQobuzCache(isrc, spotifyID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
|
||||||
client := NewSongLinkClient()
|
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
|
||||||
if err == nil && availability != nil && availability.AmazonURL != "" {
|
|
||||||
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PreWarmCache(tracksJSON string) error {
|
func PreWarmCache(tracksJSON string) error {
|
||||||
var tracks []struct {
|
var tracks []struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
|
|||||||
@@ -34,10 +34,16 @@ var (
|
|||||||
downloadDir string
|
downloadDir string
|
||||||
downloadDirMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
|
|
||||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
multiMu sync.RWMutex
|
multiMu sync.RWMutex
|
||||||
|
multiProgressDirty = true
|
||||||
|
cachedMultiProgress = "{\"items\":{}}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func markMultiProgressDirtyLocked() {
|
||||||
|
multiProgressDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
@@ -58,13 +64,25 @@ func getProgress() DownloadProgress {
|
|||||||
|
|
||||||
func GetMultiProgress() string {
|
func GetMultiProgress() string {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
if !multiProgressDirty {
|
||||||
|
cached := cachedMultiProgress
|
||||||
|
multiMu.RUnlock()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
multiMu.RUnlock()
|
||||||
|
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
if !multiProgressDirty {
|
||||||
|
return cachedMultiProgress
|
||||||
|
}
|
||||||
jsonBytes, err := json.Marshal(multiProgress)
|
jsonBytes, err := json.Marshal(multiProgress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "{\"items\":{}}"
|
return "{\"items\":{}}"
|
||||||
}
|
}
|
||||||
return string(jsonBytes)
|
cachedMultiProgress = string(jsonBytes)
|
||||||
|
multiProgressDirty = false
|
||||||
|
return cachedMultiProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetItemProgress(itemID string) string {
|
func GetItemProgress(itemID string) string {
|
||||||
@@ -90,6 +108,7 @@ func StartItemProgress(itemID string) {
|
|||||||
IsDownloading: true,
|
IsDownloading: true,
|
||||||
Status: "downloading",
|
Status: "downloading",
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetItemBytesTotal(itemID string, total int64) {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
@@ -98,6 +117,7 @@ func SetItemBytesTotal(itemID string, total int64) {
|
|||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.BytesTotal = total
|
item.BytesTotal = total
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +130,7 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
if item.BytesTotal > 0 {
|
if item.BytesTotal > 0 {
|
||||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +144,7 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
|||||||
if item.BytesTotal > 0 {
|
if item.BytesTotal > 0 {
|
||||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +156,7 @@ func CompleteItemProgress(itemID string) {
|
|||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.IsDownloading = false
|
item.IsDownloading = false
|
||||||
item.Status = "completed"
|
item.Status = "completed"
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +172,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
|||||||
if bytesTotal > 0 {
|
if bytesTotal > 0 {
|
||||||
item.BytesTotal = bytesTotal
|
item.BytesTotal = bytesTotal
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +183,7 @@ func SetItemFinalizing(itemID string) {
|
|||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.Status = "finalizing"
|
item.Status = "finalizing"
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +192,7 @@ func RemoveItemProgress(itemID string) {
|
|||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
delete(multiProgress.Items, itemID)
|
delete(multiProgress.Items, itemID)
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func ClearAllItemProgress() {
|
func ClearAllItemProgress() {
|
||||||
@@ -174,6 +200,7 @@ func ClearAllItemProgress() {
|
|||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
|
|||||||
@@ -1,8 +1,133 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildTestQobuzAlbum(id, title, artist string, tracks ...QobuzTrack) *qobuzAlbumDetails {
|
||||||
|
album := &qobuzAlbumDetails{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
ReleaseDateOriginal: "2013-05-20",
|
||||||
|
TracksCount: len(tracks),
|
||||||
|
ProductType: "album",
|
||||||
|
ReleaseType: "album",
|
||||||
|
}
|
||||||
|
album.Artist = qobuzArtistRef{ID: 1, Name: artist}
|
||||||
|
album.Artists = []qobuzArtistRef{{ID: 1, Name: artist}}
|
||||||
|
album.Tracks.Items = tracks
|
||||||
|
return album
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseQobuzURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantType string
|
||||||
|
wantID string
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "store album url",
|
||||||
|
input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985",
|
||||||
|
wantType: "album",
|
||||||
|
wantID: "0886446451985",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "store playlist url",
|
||||||
|
input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430",
|
||||||
|
wantType: "playlist",
|
||||||
|
wantID: "2049430",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "store artist url",
|
||||||
|
input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886",
|
||||||
|
wantType: "artist",
|
||||||
|
wantID: "729886",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "play track url",
|
||||||
|
input: "https://play.qobuz.com/track/40681594",
|
||||||
|
wantType: "track",
|
||||||
|
wantID: "40681594",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom scheme playlist url",
|
||||||
|
input: "qobuzapp://playlist/2049430",
|
||||||
|
wantType: "playlist",
|
||||||
|
wantID: "2049430",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported url",
|
||||||
|
input: "https://example.com/not-qobuz",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
gotType, gotID, err := parseQobuzURL(test.input)
|
||||||
|
if test.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if gotType != test.wantType || gotID != test.wantID {
|
||||||
|
t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractQobuzArtistAlbumIDs(t *testing.T) {
|
||||||
|
body := []byte(`
|
||||||
|
<div class="product__item">
|
||||||
|
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
||||||
|
</div>
|
||||||
|
<div class="product__item">
|
||||||
|
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
|
||||||
|
</div>
|
||||||
|
<div class="product__item">
|
||||||
|
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
|
||||||
|
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
|
||||||
|
if len(matches) != 3 {
|
||||||
|
t.Fatalf("expected 3 regex matches, got %d", len(matches))
|
||||||
|
}
|
||||||
|
if string(matches[0][1]) != "yrpbt0lwm3g0y" {
|
||||||
|
t.Fatalf("unexpected first album id: %q", matches[0][1])
|
||||||
|
}
|
||||||
|
if string(matches[2][1]) != "0886446451985" {
|
||||||
|
t.Fatalf("unexpected last album id: %q", matches[2][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||||
|
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
|
||||||
|
body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`)
|
||||||
|
|
||||||
|
info, err := extractQobuzDownloadInfoFromBody(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if info.DownloadURL != "https://example.test/new.flac" {
|
||||||
|
t.Fatalf("unexpected URL: %q", info.DownloadURL)
|
||||||
|
}
|
||||||
|
if info.BitDepth != 24 {
|
||||||
|
t.Fatalf("unexpected bit depth: %d", info.BitDepth)
|
||||||
|
}
|
||||||
|
if info.SampleRate != 96000 {
|
||||||
|
t.Fatalf("unexpected sample rate: %d", info.SampleRate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("reads nested data.url", func(t *testing.T) {
|
t.Run("reads nested data.url", func(t *testing.T) {
|
||||||
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
||||||
|
|
||||||
@@ -44,4 +169,387 @@ func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
|||||||
t.Fatalf("expected blocked error, got %v", err)
|
t.Fatalf("expected blocked error, got %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("returns detail error", func(t *testing.T) {
|
||||||
|
body := []byte(`{"detail":"Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']"}`)
|
||||||
|
|
||||||
|
_, err := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if err == nil || err.Error() != "Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']" {
|
||||||
|
t.Fatalf("expected detail error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeQobuzQualityCode(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"": "6",
|
||||||
|
"5": "6",
|
||||||
|
"6": "6",
|
||||||
|
"cd": "6",
|
||||||
|
"lossless": "6",
|
||||||
|
"7": "7",
|
||||||
|
"hi-res": "7",
|
||||||
|
"27": "27",
|
||||||
|
"hi-res-max": "27",
|
||||||
|
"unexpected": "6",
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, want := range tests {
|
||||||
|
if got := normalizeQobuzQualityCode(input); got != want {
|
||||||
|
t.Fatalf("normalizeQobuzQualityCode(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||||
|
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||||
|
t.Fatalf("payload is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
|
||||||
|
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
|
||||||
|
}
|
||||||
|
if got := payload["quality"]; got != "hi-res" {
|
||||||
|
t.Fatalf("payload quality = %v, want hi-res", got)
|
||||||
|
}
|
||||||
|
if got := payload["upload_to_r2"]; got != false {
|
||||||
|
t.Fatalf("payload upload_to_r2 = %v, want false", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||||
|
body := []byte(`
|
||||||
|
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||||
|
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||||
|
<button data-itemtype="album" data-itemId="pvv406bth40ya"></button>
|
||||||
|
`)
|
||||||
|
|
||||||
|
got := extractQobuzAlbumIDsFromArtistHTML(body)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" {
|
||||||
|
t.Fatalf("unexpected album IDs: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQobuzAvailableProviders(t *testing.T) {
|
||||||
|
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||||
|
if len(providers) != 6 {
|
||||||
|
t.Fatalf("expected 6 Qobuz providers, got %d", len(providers))
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]string{
|
||||||
|
"musicdl": qobuzAPIKindMusicDL,
|
||||||
|
"zarz": qobuzAPIKindMusicDL,
|
||||||
|
"dabmusic": qobuzAPIKindStandard,
|
||||||
|
"deeb": qobuzAPIKindStandard,
|
||||||
|
"qbz": qobuzAPIKindStandard,
|
||||||
|
"squid": qobuzAPIKindStandard,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, provider := range providers {
|
||||||
|
wantKind, ok := want[provider.Name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected provider %q", provider.Name)
|
||||||
|
}
|
||||||
|
if provider.Kind != wantKind {
|
||||||
|
t.Fatalf("provider %q has kind %q, want %q", provider.Name, provider.Kind, wantKind)
|
||||||
|
}
|
||||||
|
delete(want, provider.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(want) != 0 {
|
||||||
|
t.Fatalf("missing providers: %v", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
|
||||||
|
track := &QobuzTrack{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Duration: duration,
|
||||||
|
}
|
||||||
|
track.Performer.Name = artist
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectQobuzTracksFromAlbumSearchResultsPrefersMatchingTrack(t *testing.T) {
|
||||||
|
summaries := []qobuzAlbumDetails{
|
||||||
|
{ID: "album-a"},
|
||||||
|
{ID: "album-b"},
|
||||||
|
}
|
||||||
|
|
||||||
|
match := *testQobuzTrack(1, "Get Lucky", "Daft Punk", 369)
|
||||||
|
other := *testQobuzTrack(2, "Fragments of Time", "Daft Punk", 280)
|
||||||
|
fallback := *testQobuzTrack(3, "Da Funk", "Daft Punk", 330)
|
||||||
|
|
||||||
|
albums := map[string]*qobuzAlbumDetails{
|
||||||
|
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", match, other),
|
||||||
|
"album-b": buildTestQobuzAlbum("album-b", "Homework", "Daft Punk", fallback),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||||
|
"daft punk get lucky",
|
||||||
|
3,
|
||||||
|
summaries,
|
||||||
|
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
t.Fatal("expected tracks, got none")
|
||||||
|
}
|
||||||
|
if tracks[0].ID != 1 {
|
||||||
|
t.Fatalf("expected Get Lucky to rank first, got track id %d", tracks[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectQobuzTracksFromAlbumSearchResultsDedupesTracks(t *testing.T) {
|
||||||
|
summaries := []qobuzAlbumDetails{
|
||||||
|
{ID: "album-a"},
|
||||||
|
{ID: "album-b"},
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := *testQobuzTrack(42, "Get Lucky", "Daft Punk", 369)
|
||||||
|
|
||||||
|
albums := map[string]*qobuzAlbumDetails{
|
||||||
|
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", shared),
|
||||||
|
"album-b": buildTestQobuzAlbum("album-b", "Random Access Memories Deluxe", "Daft Punk", shared),
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks, err := selectQobuzTracksFromAlbumSearchResults(
|
||||||
|
"daft punk get lucky",
|
||||||
|
5,
|
||||||
|
summaries,
|
||||||
|
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tracks) != 1 {
|
||||||
|
t.Fatalf("expected 1 deduped track, got %d", len(tracks))
|
||||||
|
}
|
||||||
|
if tracks[0].ID != 42 {
|
||||||
|
t.Fatalf("unexpected deduped track id: %d", tracks[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
||||||
|
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||||
|
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||||
|
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||||
|
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||||
|
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||||
|
GetTrackIDCache().Clear()
|
||||||
|
})
|
||||||
|
GetTrackIDCache().Clear()
|
||||||
|
|
||||||
|
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||||
|
if trackID != 111 {
|
||||||
|
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
if isrc != "TESTISRC1" {
|
||||||
|
t.Fatalf("unexpected ISRC lookup: %q", isrc)
|
||||||
|
}
|
||||||
|
if expectedDurationSec != 180 {
|
||||||
|
t.Fatalf("unexpected duration: %d", expectedDurationSec)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||||
|
t.Fatal("metadata fallback should not run when ISRC fallback succeeds")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
|
if spotifyTrackID != "spotify-track-id" {
|
||||||
|
t.Fatalf("unexpected spotify ID: %q", spotifyTrackID)
|
||||||
|
}
|
||||||
|
if isrc != "TESTISRC1" {
|
||||||
|
t.Fatalf("unexpected SongLink ISRC: %q", isrc)
|
||||||
|
}
|
||||||
|
return &TrackAvailability{QobuzID: "111"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DownloadRequest{
|
||||||
|
ISRC: "TESTISRC1",
|
||||||
|
SpotifyID: "spotify-track-id",
|
||||||
|
TrackName: "Taste Back",
|
||||||
|
ArtistName: "Harry Styles",
|
||||||
|
DurationMS: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if track == nil || track.ID != 222 || track.Title != "Taste Back" {
|
||||||
|
t.Fatalf("unexpected resolved track: %+v", track)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached := GetTrackIDCache().Get(req.ISRC)
|
||||||
|
if cached == nil || cached.QobuzTrackID != 222 {
|
||||||
|
t.Fatalf("expected validated fallback track to be cached, got %+v", cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
|
||||||
|
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||||
|
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||||
|
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||||
|
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||||
|
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||||
|
})
|
||||||
|
|
||||||
|
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||||
|
if trackID != 333 {
|
||||||
|
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||||
|
t.Fatal("ISRC fallback should not run without an ISRC")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
|
||||||
|
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
|
||||||
|
}
|
||||||
|
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||||
|
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DownloadRequest{
|
||||||
|
QobuzID: "333",
|
||||||
|
TrackName: "Taste Back",
|
||||||
|
ArtistName: "Harry Styles",
|
||||||
|
DurationMS: 181000,
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
|
||||||
|
t.Fatalf("unexpected resolved track: %+v", track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) {
|
||||||
|
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||||
|
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||||
|
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||||
|
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||||
|
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||||
|
})
|
||||||
|
|
||||||
|
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||||
|
if trackID != 40681594 {
|
||||||
|
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||||
|
}
|
||||||
|
return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||||
|
t.Fatal("ISRC fallback should not run when request qobuz id succeeds")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||||
|
t.Fatal("metadata fallback should not run when request qobuz id succeeds")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||||
|
t.Fatal("SongLink should not run when request qobuz id is provided")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := DownloadRequest{
|
||||||
|
QobuzID: "qobuz:40681594",
|
||||||
|
TrackName: "Sign of the Times",
|
||||||
|
ArtistName: "Harry Styles",
|
||||||
|
DurationMS: 341000,
|
||||||
|
}
|
||||||
|
|
||||||
|
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if track == nil || track.ID != 40681594 {
|
||||||
|
t.Fatalf("unexpected resolved track: %+v", track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
}
|
||||||
|
|
||||||
|
track := &QobuzTrack{
|
||||||
|
Title: "Different Title",
|
||||||
|
Duration: 0,
|
||||||
|
}
|
||||||
|
track.Performer.Name = "Different Artist"
|
||||||
|
|
||||||
|
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
|
||||||
|
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQobuzTrackMetadataIncludesComposer(t *testing.T) {
|
||||||
|
track := &QobuzTrack{
|
||||||
|
ID: 40681594,
|
||||||
|
Title: "Sign of the Times",
|
||||||
|
ISRC: "USSM11703595",
|
||||||
|
Duration: 340,
|
||||||
|
TrackNumber: 1,
|
||||||
|
MediaNumber: 1,
|
||||||
|
}
|
||||||
|
track.Performer.ID = 729886
|
||||||
|
track.Performer.Name = "Harry Styles"
|
||||||
|
track.Composer.ID = 729886
|
||||||
|
track.Composer.Name = "Harry Styles"
|
||||||
|
track.Album.ID = "0886446451985"
|
||||||
|
track.Album.Title = "Harry Styles"
|
||||||
|
track.Album.ReleaseDate = "2017-05-12"
|
||||||
|
track.Album.TracksCount = 10
|
||||||
|
track.Album.ReleaseType = "album"
|
||||||
|
track.Album.ProductType = "album"
|
||||||
|
track.Album.Artist.ID = 729886
|
||||||
|
track.Album.Artist.Name = "Harry Styles"
|
||||||
|
track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}}
|
||||||
|
|
||||||
|
trackMeta := qobuzTrackToTrackMetadata(track)
|
||||||
|
if trackMeta.Composer != "Harry Styles" {
|
||||||
|
t.Fatalf("track composer = %q", trackMeta.Composer)
|
||||||
|
}
|
||||||
|
|
||||||
|
albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track)
|
||||||
|
if albumTrackMeta.Composer != "Harry Styles" {
|
||||||
|
t.Fatalf("album track composer = %q", albumTrackMeta.Composer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ func (r *RateLimiter) WaitForSlot() {
|
|||||||
r.timestamps = append(r.timestamps, time.Now())
|
r.timestamps = append(r.timestamps, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanOldTimestamps removes timestamps that are outside the current window
|
|
||||||
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
||||||
cutoff := now.Add(-r.window)
|
cutoff := now.Add(-r.window)
|
||||||
validStart := 0
|
validStart := 0
|
||||||
|
|||||||
@@ -16,16 +16,13 @@ var hiraganaToRomaji = map[rune]string{
|
|||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
// Dakuten (voiced)
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
// Handakuten (semi-voiced)
|
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
// Small characters
|
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
'っ': "", // Double consonant marker
|
'っ': "",
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,19 +37,15 @@ var katakanaToRomaji = map[rune]string{
|
|||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
// Dakuten (voiced)
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
// Handakuten (semi-voiced)
|
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
// Small characters
|
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
'ッ': "", // Double consonant marker
|
'ッ': "",
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
// Extended katakana
|
'ー': "",
|
||||||
'ー': "", // Long vowel mark
|
|
||||||
'ヴ': "vu",
|
'ヴ': "vu",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +75,6 @@ var combinationKatakana = map[string]string{
|
|||||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
// Extended combinations
|
|
||||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
@@ -120,7 +112,6 @@ func JapaneseToRomaji(text string) string {
|
|||||||
i := 0
|
i := 0
|
||||||
|
|
||||||
for i < len(runes) {
|
for i < len(runes) {
|
||||||
// Check for っ/ッ (double consonant)
|
|
||||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||||
nextRomaji := ""
|
nextRomaji := ""
|
||||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||||
@@ -129,13 +120,12 @@ func JapaneseToRomaji(text string) string {
|
|||||||
nextRomaji = romaji
|
nextRomaji = romaji
|
||||||
}
|
}
|
||||||
if len(nextRomaji) > 0 {
|
if len(nextRomaji) > 0 {
|
||||||
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
result.WriteByte(nextRomaji[0])
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for two-character combinations
|
|
||||||
if i < len(runes)-1 {
|
if i < len(runes)-1 {
|
||||||
combo := string(runes[i : i+2])
|
combo := string(runes[i : i+2])
|
||||||
if romaji, ok := combinationHiragana[combo]; ok {
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
@@ -150,17 +140,14 @@ func JapaneseToRomaji(text string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single character conversion
|
|
||||||
r := runes[i]
|
r := runes[i]
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
} else if isKanji(r) {
|
} else if isKanji(r) {
|
||||||
// Keep kanji as-is (would need dictionary for proper conversion)
|
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
} else {
|
} else {
|
||||||
// Keep other characters (punctuation, spaces, etc.)
|
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
@@ -170,11 +157,9 @@ func JapaneseToRomaji(text string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BuildSearchQuery(trackName, artistName string) string {
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
// Convert Japanese to romaji
|
|
||||||
trackRomaji := JapaneseToRomaji(trackName)
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
artistRomaji := JapaneseToRomaji(artistName)
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
// Clean up the query - remove special characters that might interfere with search
|
|
||||||
trackClean := cleanSearchQuery(trackRomaji)
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
artistClean := cleanSearchQuery(artistRomaji)
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
|
|
||||||
@@ -196,16 +181,13 @@ func cleanSearchQuery(s string) string {
|
|||||||
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 {
|
||||||
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
} else if r == ',' || r == '.' {
|
} else if r == ',' || r == '.' {
|
||||||
// Convert punctuation to space
|
|
||||||
result.WriteRune(' ')
|
result.WriteRune(' ')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clean up multiple spaces
|
|
||||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSanitizeSensitiveLogText(t *testing.T) {
|
|
||||||
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
|
||||||
redacted := sanitizeSensitiveLogText(input)
|
|
||||||
|
|
||||||
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
|
||||||
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
|
||||||
}
|
|
||||||
if !strings.Contains(redacted, "[REDACTED]") {
|
|
||||||
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateExtensionAuthURL(t *testing.T) {
|
|
||||||
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
|
||||||
t.Fatalf("expected valid auth URL, got error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
blocked := []string{
|
|
||||||
"http://accounts.example.com/oauth/authorize",
|
|
||||||
"https://user:pass@accounts.example.com/oauth/authorize",
|
|
||||||
"https://localhost/oauth/authorize",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rawURL := range blocked {
|
|
||||||
if err := validateExtensionAuthURL(rawURL); err == nil {
|
|
||||||
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
|
||||||
ext := &LoadedExtension{
|
|
||||||
ID: "test-ext",
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Name: "test-ext",
|
|
||||||
Permissions: ExtensionPermissions{
|
|
||||||
Network: []string{"api.example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
|
||||||
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
|
||||||
t.Fatal("expected embedded URL credentials to be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
|
||||||
baseDir := t.TempDir()
|
|
||||||
|
|
||||||
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isPathWithinBase(baseDir, destPath) {
|
|
||||||
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
baseName := filepath.Base(destPath)
|
|
||||||
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
|
||||||
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
|
||||||
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
|
||||||
t.Fatal("expected empty extension id to be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,6 +15,10 @@ type SongLinkClient struct {
|
|||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type songLinkPlatformLink struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
type TrackAvailability struct {
|
type TrackAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
@@ -35,6 +40,15 @@ type TrackAvailability struct {
|
|||||||
var (
|
var (
|
||||||
globalSongLinkClient *SongLinkClient
|
globalSongLinkClient *SongLinkClient
|
||||||
songLinkClientOnce sync.Once
|
songLinkClientOnce sync.Once
|
||||||
|
songLinkRegion = "US"
|
||||||
|
songLinkRegionMu sync.RWMutex
|
||||||
|
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||||
|
return GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||||
|
}
|
||||||
|
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
}
|
||||||
|
songLinkRetryConfig = DefaultRetryConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
@@ -46,101 +60,303 @@ func NewSongLinkClient() *SongLinkClient {
|
|||||||
return globalSongLinkClient
|
return globalSongLinkClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func normalizeSongLinkRegion(region string) string {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
normalized := strings.ToUpper(strings.TrimSpace(region))
|
||||||
|
if len(normalized) != 2 {
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
return "US"
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
|
||||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
}
|
||||||
|
for _, ch := range normalized {
|
||||||
|
if ch < 'A' || ch > 'Z' {
|
||||||
|
return "US"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
func SetSongLinkRegion(region string) {
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
normalized := normalizeSongLinkRegion(region)
|
||||||
|
songLinkRegionMu.Lock()
|
||||||
|
songLinkRegion = normalized
|
||||||
|
songLinkRegionMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSongLinkRegion() string {
|
||||||
|
songLinkRegionMu.RLock()
|
||||||
|
region := songLinkRegion
|
||||||
|
songLinkRegionMu.RUnlock()
|
||||||
|
return region
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAPIURL = "https://api.zarz.moe/v1/resolve"
|
||||||
|
|
||||||
|
func songLinkBaseURL() string {
|
||||||
|
return "https://api.song.link/v1-alpha.1/links"
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTrackPlatforms resolves a music URL to all platforms.
|
||||||
|
// Spotify URLs use the resolve API; if that fails, falls back to SongLink.
|
||||||
|
// All other URLs go directly to SongLink.
|
||||||
|
func (s *SongLinkClient) resolveTrackPlatforms(inputURL string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
if isSpotifyURL(inputURL) {
|
||||||
|
payload, err := json.Marshal(map[string]string{"url": inputURL})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
|
||||||
|
}
|
||||||
|
links, err := s.doResolveRequest(payload)
|
||||||
|
if err == nil {
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
GoLog("[SongLink] Resolve proxy failed for %s: %v, falling back to SongLink", inputURL, err)
|
||||||
|
return s.songLinkByTargetURL(inputURL)
|
||||||
|
}
|
||||||
|
return s.songLinkByTargetURL(inputURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTrackPlatformsByPlatform resolves using platform + type + id.
|
||||||
|
// Spotify uses the resolve API with SongLink fallback; all other platforms use SongLink directly.
|
||||||
|
func (s *SongLinkClient) resolveTrackPlatformsByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
if strings.EqualFold(platform, "spotify") {
|
||||||
|
payload, err := json.Marshal(map[string]string{
|
||||||
|
"platform": platform,
|
||||||
|
"type": entityType,
|
||||||
|
"id": entityID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
|
||||||
|
}
|
||||||
|
links, err := s.doResolveRequest(payload)
|
||||||
|
if err == nil {
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
GoLog("[SongLink] Resolve proxy failed for %s/%s/%s: %v, falling back to SongLink", platform, entityType, entityID, err)
|
||||||
|
return s.songLinkByPlatform(platform, entityType, entityID)
|
||||||
|
}
|
||||||
|
return s.songLinkByPlatform(platform, entityType, entityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSpotifyURL(u string) bool {
|
||||||
|
lower := strings.ToLower(u)
|
||||||
|
return strings.Contains(lower, "spotify.com/") || strings.Contains(lower, "spotify:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// doResolveRequest sends a JSON payload to the resolve API (api.zarz.moe)
|
||||||
|
// and parses the response into a platform link map.
|
||||||
|
func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPlatformLink, error) {
|
||||||
|
req, err := http.NewRequest("POST", resolveAPIURL, bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
return nil, fmt.Errorf("failed to create resolve request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve API request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode == 400 {
|
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 404 {
|
|
||||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("resolve API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
body, err := ReadResponseBody(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
return nil, fmt.Errorf("failed to read resolve response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var songLinkResp struct {
|
var resolveResp struct {
|
||||||
LinksByPlatform map[string]struct {
|
Success bool `json:"success"`
|
||||||
URL string `json:"url"`
|
ISRC string `json:"isrc"`
|
||||||
} `json:"linksByPlatform"`
|
SongUrls map[string]json.RawMessage `json:"songUrls"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &resolveResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
|
||||||
|
}
|
||||||
|
if !resolveResp.Success {
|
||||||
|
return nil, fmt.Errorf("resolve API returned success=false")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
keyMap := map[string]string{
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
"Spotify": "spotify",
|
||||||
|
"Deezer": "deezer",
|
||||||
|
"Tidal": "tidal",
|
||||||
|
"YouTubeMusic": "youtubeMusic",
|
||||||
|
"YouTube": "youtube",
|
||||||
|
"AmazonMusic": "amazonMusic",
|
||||||
|
"Qobuz": "qobuz",
|
||||||
|
"AppleMusic": "appleMusic",
|
||||||
}
|
}
|
||||||
|
|
||||||
availability := &TrackAvailability{
|
links := make(map[string]songLinkPlatformLink)
|
||||||
SpotifyID: spotifyTrackID,
|
for resolveKey, platformKey := range keyMap {
|
||||||
}
|
rawValue, ok := resolveResp.SongUrls[resolveKey]
|
||||||
|
if !ok {
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
continue
|
||||||
availability.Tidal = true
|
}
|
||||||
availability.TidalURL = tidalLink.URL
|
if u := extractResolveURLValue(rawValue); u != "" {
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
links[platformKey] = songLinkPlatformLink{URL: u}
|
||||||
}
|
|
||||||
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
||||||
availability.Amazon = true
|
|
||||||
availability.AmazonURL = amazonLink.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
||||||
availability.Deezer = true
|
|
||||||
availability.DeezerURL = deezerLink.URL
|
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
||||||
availability.Qobuz = true
|
|
||||||
availability.QobuzURL = qobuzLink.URL
|
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to regular youtube if youtubeMusic not available
|
|
||||||
if !availability.YouTube {
|
|
||||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(links) == 0 {
|
||||||
|
return nil, fmt.Errorf("resolve API returned no platform links")
|
||||||
|
}
|
||||||
|
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractResolveURLValue(raw json.RawMessage) string {
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var direct string
|
||||||
|
if err := json.Unmarshal(trimmed, &direct); err == nil {
|
||||||
|
return strings.TrimSpace(direct)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []string
|
||||||
|
if err := json.Unmarshal(trimmed, &list); err == nil {
|
||||||
|
for _, candidate := range list {
|
||||||
|
if cleaned := strings.TrimSpace(candidate); cleaned != "" {
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs).
|
||||||
|
func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s?url=%s&userCountry=%s",
|
||||||
|
songLinkBaseURL(),
|
||||||
|
url.QueryEscape(targetURL),
|
||||||
|
url.QueryEscape(GetSongLinkRegion()))
|
||||||
|
|
||||||
|
return s.doSongLinkRequest(apiURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// songLinkByPlatform calls the SongLink API with platform + type + id (for non-Spotify platforms).
|
||||||
|
func (s *SongLinkClient) songLinkByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s&userCountry=%s",
|
||||||
|
songLinkBaseURL(),
|
||||||
|
url.QueryEscape(platform),
|
||||||
|
url.QueryEscape(entityType),
|
||||||
|
url.QueryEscape(entityID),
|
||||||
|
url.QueryEscape(GetSongLinkRegion()))
|
||||||
|
|
||||||
|
return s.doSongLinkRequest(apiURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doSongLinkRequest calls the SongLink API and parses the response.
|
||||||
|
func (s *SongLinkClient) doSongLinkRequest(apiURL string) (map[string]songLinkPlatformLink, error) {
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SongLink request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := songLinkRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SongLink request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read SongLink response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode SongLink response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(songLinkResp.LinksByPlatform) == 0 {
|
||||||
|
return nil, fmt.Errorf("SongLink returned no platform links")
|
||||||
|
}
|
||||||
|
|
||||||
|
return songLinkResp.LinksByPlatform, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
|
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
||||||
|
isrc = strings.ToUpper(strings.TrimSpace(isrc))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case spotifyTrackID != "":
|
||||||
|
return s.checkTrackAvailabilityFromSpotify(spotifyTrackID)
|
||||||
|
case isrc != "":
|
||||||
|
return s.checkTrackAvailabilityFromISRC(isrc)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("spotify track ID and ISRC are empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
|
links, err := s.resolveTrackPlatforms(spotifyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve proxy failed for Spotify %s: %w", spotifyTrackID, err)
|
||||||
|
}
|
||||||
|
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, links), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
track, err := songLinkSearchByISRC(ctx, isrc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve Deezer track from ISRC %s: %w", isrc, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deezerTrackID := songLinkExtractDeezerTrackID(track)
|
||||||
|
if deezerTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := songLinkCheckAvailabilityFromDeezer(s, deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve SongLink availability from ISRC %s via Deezer %s: %w", isrc, deezerTrackID, err)
|
||||||
|
}
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func songLinkExtractDeezerTrackID(track *TrackMetadata) string {
|
||||||
|
if track == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if deezerID, ok := strings.CutPrefix(strings.TrimSpace(track.SpotifyID), "deezer:"); ok {
|
||||||
|
deezerID = strings.TrimSpace(deezerID)
|
||||||
|
if deezerID != "" {
|
||||||
|
return deezerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if deezerID := extractDeezerIDFromURL(strings.TrimSpace(track.ExternalURL)); deezerID != "" {
|
||||||
|
return deezerID
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
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 {
|
||||||
@@ -158,7 +374,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
|
|||||||
return urls, nil
|
return urls, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
|
||||||
func extractDeezerIDFromURL(deezerURL string) string {
|
func extractDeezerIDFromURL(deezerURL string) string {
|
||||||
parts := strings.Split(deezerURL, "/")
|
parts := strings.Split(deezerURL, "/")
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
@@ -171,7 +386,7 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
// extractQobuzIDFromURL extracts Qobuz track ID from URL.
|
||||||
// URL formats:
|
// URL formats:
|
||||||
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||||
// - https://open.qobuz.com/track/12345678
|
// - https://open.qobuz.com/track/12345678
|
||||||
@@ -182,29 +397,24 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find /track/ID pattern first
|
|
||||||
if strings.Contains(qobuzURL, "/track/") {
|
if strings.Contains(qobuzURL, "/track/") {
|
||||||
parts := strings.Split(qobuzURL, "/track/")
|
parts := strings.Split(qobuzURL, "/track/")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
idPart := parts[1]
|
idPart := parts[1]
|
||||||
// Remove query parameters
|
|
||||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
}
|
}
|
||||||
// Remove trailing slash or path
|
|
||||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
}
|
}
|
||||||
idPart = strings.TrimSpace(idPart)
|
idPart = strings.TrimSpace(idPart)
|
||||||
// Validate it's a number
|
|
||||||
if idPart != "" && isNumeric(idPart) {
|
if idPart != "" && isNumeric(idPart) {
|
||||||
return idPart
|
return idPart
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract from album URL with track highlight
|
// Try to extract from album URL with track highlight (e.g. ?trackId=12345678)
|
||||||
// Format: /album/albumname/trackid or ?trackId=12345678
|
|
||||||
if strings.Contains(qobuzURL, "trackId=") {
|
if strings.Contains(qobuzURL, "trackId=") {
|
||||||
parts := strings.Split(qobuzURL, "trackId=")
|
parts := strings.Split(qobuzURL, "trackId=")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
@@ -223,7 +433,6 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
|||||||
parts := strings.Split(qobuzURL, "/")
|
parts := strings.Split(qobuzURL, "/")
|
||||||
for i := len(parts) - 1; i >= 0; i-- {
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
part := parts[i]
|
part := parts[i]
|
||||||
// Remove query parameters
|
|
||||||
if idx := strings.Index(part, "?"); idx > 0 {
|
if idx := strings.Index(part, "?"); idx > 0 {
|
||||||
part = part[:idx]
|
part = part[:idx]
|
||||||
}
|
}
|
||||||
@@ -236,10 +445,6 @@ func extractQobuzIDFromURL(qobuzURL string) string {
|
|||||||
return ""
|
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 {
|
func extractTidalIDFromURL(tidalURL string) string {
|
||||||
if tidalURL == "" {
|
if tidalURL == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -265,17 +470,11 @@ func extractTidalIDFromURL(tidalURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractYouTubeIDFromURL extracts YouTube video ID from URL
|
|
||||||
// URL formats:
|
|
||||||
// - https://www.youtube.com/watch?v=VIDEO_ID
|
|
||||||
// - https://youtu.be/VIDEO_ID
|
|
||||||
// - https://music.youtube.com/watch?v=VIDEO_ID
|
|
||||||
func extractYouTubeIDFromURL(youtubeURL string) string {
|
func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||||
if youtubeURL == "" {
|
if youtubeURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle youtu.be short URLs
|
|
||||||
if strings.Contains(youtubeURL, "youtu.be/") {
|
if strings.Contains(youtubeURL, "youtu.be/") {
|
||||||
parts := strings.Split(youtubeURL, "youtu.be/")
|
parts := strings.Split(youtubeURL, "youtu.be/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -290,7 +489,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle youtube.com URLs with ?v= parameter
|
|
||||||
parsed, err := url.Parse(youtubeURL)
|
parsed, err := url.Parse(youtubeURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -300,7 +498,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle /embed/ format
|
|
||||||
if strings.Contains(parsed.Path, "/embed/") {
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
parts := strings.Split(parsed.Path, "/embed/")
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -311,8 +508,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string {
|
|||||||
return ""
|
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 {
|
||||||
@@ -326,7 +521,6 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
|
|||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
|
|
||||||
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -340,7 +534,6 @@ func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string
|
|||||||
return availability.YouTubeURL, nil
|
return availability.YouTubeURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumAvailability represents album availability on different platforms
|
|
||||||
type AlbumAvailability struct {
|
type AlbumAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Deezer bool `json:"deezer"`
|
Deezer bool `json:"deezer"`
|
||||||
@@ -349,50 +542,17 @@ type AlbumAvailability struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
|
||||||
|
links, err := s.resolveTrackPlatforms(spotifyURL)
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
|
||||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("resolve proxy failed for album %s: %w", spotifyAlbumID, err)
|
||||||
}
|
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
availability := &AlbumAvailability{
|
availability := &AlbumAvailability{
|
||||||
SpotifyID: spotifyAlbumID,
|
SpotifyID: spotifyAlbumID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
@@ -401,7 +561,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
|
|
||||||
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
|
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
|
||||||
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
|
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -435,104 +594,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
|
||||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
|
||||||
|
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
links, err := s.resolveTrackPlatforms(deezerURL)
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
|
||||||
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("resolve failed for Deezer %s: %w", deezerTrackID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
availability := buildTrackAvailabilityFromSongLinkLinks("", links)
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
// Ensure Deezer is always marked available since we started from a Deezer URL
|
||||||
if err != nil {
|
availability.Deezer = true
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
availability.DeezerID = deezerTrackID
|
||||||
|
if availability.DeezerURL == "" {
|
||||||
|
availability.DeezerURL = deezerURL
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 400 {
|
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 404 {
|
|
||||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
EntitiesByUniqueId map[string]struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
ArtistName string `json:"artistName"`
|
|
||||||
} `json:"entitiesByUniqueId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
availability := &TrackAvailability{
|
|
||||||
Deezer: true,
|
|
||||||
DeezerID: deezerTrackID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
||||||
availability.Tidal = true
|
|
||||||
availability.TidalURL = tidalLink.URL
|
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
||||||
availability.Amazon = true
|
|
||||||
availability.AmazonURL = amazonLink.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
||||||
availability.Qobuz = true
|
|
||||||
availability.QobuzURL = qobuzLink.URL
|
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
||||||
availability.DeezerURL = deezerLink.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
||||||
}
|
|
||||||
if !availability.YouTube {
|
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,99 +619,59 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
links, err := s.resolveTrackPlatformsByPlatform(platform, entityType, entityID)
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
|
||||||
url.QueryEscape(platform),
|
|
||||||
url.QueryEscape(entityType),
|
|
||||||
url.QueryEscape(entityID))
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("resolve failed for %s %s: %w", platform, entityID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
||||||
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 {
|
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
availability := &TrackAvailability{
|
||||||
}
|
SpotifyID: spotifyTrackID,
|
||||||
if resp.StatusCode == 404 {
|
|
||||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
if availability.SpotifyID == "" {
|
||||||
if err != nil {
|
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
availability := &TrackAvailability{}
|
|
||||||
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
}
|
}
|
||||||
|
if amazonLink, ok := links["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
|
||||||
}
|
}
|
||||||
|
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
||||||
availability.Qobuz = true
|
availability.Qobuz = true
|
||||||
availability.QobuzURL = qobuzLink.URL
|
availability.QobuzURL = qobuzLink.URL
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
}
|
}
|
||||||
|
if deezerLink, ok := links["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
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
availability.YouTube = true
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
}
|
}
|
||||||
if !availability.YouTube {
|
if !availability.YouTube {
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
availability.YouTube = true
|
availability.YouTube = true
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability, nil
|
return availability
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
|
||||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
parts := strings.Split(spotifyURL, "/track/")
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
@@ -662,7 +697,6 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e
|
|||||||
return availability.SpotifyID, nil
|
return availability.SpotifyID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
|
|
||||||
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -689,7 +723,6 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
|
|||||||
return availability.AmazonURL, nil
|
return availability.AmazonURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
|
|
||||||
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
|
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -704,85 +737,10 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
links, err := s.resolveTrackPlatforms(inputURL)
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("resolve failed for URL %s: %w", inputURL, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
|
||||||
return nil, fmt.Errorf("track not found on SongLink")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
EntityID string `json:"entityUniqueId"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
availability := &TrackAvailability{}
|
|
||||||
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
|
||||||
}
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
|
||||||
availability.Tidal = true
|
|
||||||
availability.TidalURL = tidalLink.URL
|
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
|
||||||
}
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
|
||||||
availability.Amazon = true
|
|
||||||
availability.AmazonURL = amazonLink.URL
|
|
||||||
}
|
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
|
||||||
availability.Qobuz = true
|
|
||||||
availability.QobuzURL = qobuzLink.URL
|
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
|
||||||
}
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
|
||||||
availability.Deezer = true
|
|
||||||
availability.DeezerURL = deezerLink.URL
|
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
||||||
}
|
|
||||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
||||||
}
|
|
||||||
if !availability.YouTube {
|
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return availability, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return fn(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
||||||
|
resp := &http.Response{
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := getRetryAfterDuration(resp); got != 0 {
|
||||||
|
t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) {
|
||||||
|
origRetryConfig := songLinkRetryConfig
|
||||||
|
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||||
|
|
||||||
|
client := &SongLinkClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
|
||||||
|
body := `{"success":true,"isrc":"USRC12345678","songUrls":{"Spotify":"https://open.spotify.com/track/testspotifyid","Deezer":"https://www.deezer.com/track/908604612","AmazonMusic":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","Tidal":"https://listen.tidal.com/track/134858527","Qobuz":"https://open.qobuz.com/track/195125822","YouTubeMusic":"https://music.youtube.com/watch?v=testvideoid1"}}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID != "testspotifyid" {
|
||||||
|
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||||
|
}
|
||||||
|
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||||
|
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||||
|
}
|
||||||
|
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||||
|
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||||
|
}
|
||||||
|
if availability.YouTubeID != "testvideoid1" {
|
||||||
|
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
|
||||||
|
origRetryConfig := songLinkRetryConfig
|
||||||
|
songLinkRetryConfig = func() RetryConfig {
|
||||||
|
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||||
|
}
|
||||||
|
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||||
|
|
||||||
|
var hitSongLink bool
|
||||||
|
|
||||||
|
client := &SongLinkClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
// Resolve proxy returns 500
|
||||||
|
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 500,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("internal error")),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
// SongLink fallback should be called
|
||||||
|
if req.URL.Host == "api.song.link" {
|
||||||
|
hitSongLink = true
|
||||||
|
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"}}}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected SongLink fallback to succeed, got error: %v", err)
|
||||||
|
}
|
||||||
|
if !hitSongLink {
|
||||||
|
t.Fatal("expected fallback request to SongLink API, but it was never called")
|
||||||
|
}
|
||||||
|
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||||
|
t.Fatalf("Deezer availability via fallback = %+v, want DeezerID 908604612", availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *testing.T) {
|
||||||
|
origRetryConfig := songLinkRetryConfig
|
||||||
|
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||||
|
|
||||||
|
client := &SongLinkClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
|
||||||
|
body := `{"success":true,"isrc":"TCAHA2367688","songUrls":{"Spotify":"https://open.spotify.com/track/5glgyj6zH0irbNGfukHacv","Deezer":"https://www.deezer.com/track/2248583177","Tidal":"https://tidal.com/browse/track/290565315","AppleMusic":"https://geo.music.apple.com/us/album/example?i=1","YouTubeMusic":null,"YouTube":"https://www.youtube.com/watch?v=wD_e59XUNdQ","AmazonMusic":"https://music.amazon.com/tracks/B0C35TG38Y/?ref=dm_ff_amazonmusic_3p","Beatport":null,"BeatSource":null,"SoundCloud":null,"Qobuz":null,"Other":[]}}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := client.CheckTrackAvailability("5glgyj6zH0irbNGfukHacv", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID != "5glgyj6zH0irbNGfukHacv" {
|
||||||
|
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "5glgyj6zH0irbNGfukHacv")
|
||||||
|
}
|
||||||
|
if !availability.Deezer || availability.DeezerID != "2248583177" {
|
||||||
|
t.Fatalf("Deezer availability = %+v, want DeezerID 2248583177", availability)
|
||||||
|
}
|
||||||
|
if !availability.Tidal || availability.TidalID != "290565315" {
|
||||||
|
t.Fatalf("Tidal availability = %+v, want TidalID 290565315", availability)
|
||||||
|
}
|
||||||
|
if availability.Qobuz {
|
||||||
|
t.Fatalf("Qobuz should remain false when resolve response contains null, got %+v", availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAvailabilityFromDeezerUsesSongLink(t *testing.T) {
|
||||||
|
origRetryConfig := songLinkRetryConfig
|
||||||
|
songLinkRetryConfig = func() RetryConfig {
|
||||||
|
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||||
|
}
|
||||||
|
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||||
|
|
||||||
|
client := &SongLinkClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
// Non-Spotify should go to SongLink, not resolve API
|
||||||
|
if req.URL.Host == "api.zarz.moe" {
|
||||||
|
t.Fatalf("non-Spotify URL should not hit resolve API, got: %s", req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if req.URL.Host == "api.song.link" {
|
||||||
|
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvid"}}}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := client.checkAvailabilityFromDeezerSongLink("908604612")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkAvailabilityFromDeezerSongLink() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||||
|
t.Fatalf("Deezer = %+v, want DeezerID 908604612", availability)
|
||||||
|
}
|
||||||
|
if availability.SpotifyID != "testid" {
|
||||||
|
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testid")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
|
||||||
|
|
||||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
|
||||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
|
||||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
|
||||||
parsed, err := parseSpotifyURI(spotifyURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
base := strings.TrimSpace(apiBaseURL)
|
|
||||||
if base == "" {
|
|
||||||
base = DefaultSpotFetchAPIBaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch parsed.Type {
|
|
||||||
case "track":
|
|
||||||
var trackResp TrackResponse
|
|
||||||
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
|
||||||
}
|
|
||||||
return trackResp, nil
|
|
||||||
case "album":
|
|
||||||
var albumResp AlbumResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
|
||||||
}
|
|
||||||
return &albumResp, nil
|
|
||||||
case "playlist":
|
|
||||||
var playlistResp PlaylistResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
|
||||||
}
|
|
||||||
return playlistResp, nil
|
|
||||||
case "artist":
|
|
||||||
var artistResp ArtistResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
|
||||||
}
|
|
||||||
return &artistResp, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseTidalURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantType string
|
||||||
|
wantID string
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "track url",
|
||||||
|
input: "https://tidal.com/track/77616174",
|
||||||
|
wantType: "track",
|
||||||
|
wantID: "77616174",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "browse album url",
|
||||||
|
input: "https://listen.tidal.com/browse/album/77616169",
|
||||||
|
wantType: "album",
|
||||||
|
wantID: "77616169",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "artist url",
|
||||||
|
input: "https://www.tidal.com/artist/3852143",
|
||||||
|
wantType: "artist",
|
||||||
|
wantID: "3852143",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "playlist url",
|
||||||
|
input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||||
|
wantType: "playlist",
|
||||||
|
wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported host",
|
||||||
|
input: "https://example.com/track/123",
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
gotType, gotID, err := parseTidalURL(test.input)
|
||||||
|
if test.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if gotType != test.wantType || gotID != test.wantID {
|
||||||
|
t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTidalRequestTrackID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want int64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{input: "40681594", want: 40681594, ok: true},
|
||||||
|
{input: "tidal:40681594", want: 40681594, ok: true},
|
||||||
|
{input: " tidal:40681594 ", want: 40681594, ok: true},
|
||||||
|
{input: "", want: 0, ok: false},
|
||||||
|
{input: "tidal:not-a-number", want: 0, ok: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
got, ok := parseTidalRequestTrackID(test.input)
|
||||||
|
if got != test.want || ok != test.ok {
|
||||||
|
t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalImageURL(t *testing.T) {
|
||||||
|
got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
|
||||||
|
want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("tidalImageURL() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalTrackToTrackMetadata(t *testing.T) {
|
||||||
|
track := &TidalTrack{
|
||||||
|
ID: 77616174,
|
||||||
|
Title: "Bruckner: Symphony No. 5",
|
||||||
|
ISRC: "GBUM71507433",
|
||||||
|
Duration: 1172,
|
||||||
|
TrackNumber: 5,
|
||||||
|
VolumeNumber: 1,
|
||||||
|
URL: "http://www.tidal.com/track/77616174",
|
||||||
|
}
|
||||||
|
track.Artist.ID = 3852143
|
||||||
|
track.Artist.Name = "Staatskapelle Berlin"
|
||||||
|
track.Artists = []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
}{
|
||||||
|
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||||
|
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||||
|
}
|
||||||
|
track.Album.ID = 77616169
|
||||||
|
track.Album.Title = "Bruckner: Symphonies 4-9"
|
||||||
|
track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
|
||||||
|
track.Album.ReleaseDate = "2016-02-26"
|
||||||
|
|
||||||
|
got := tidalTrackToTrackMetadata(track)
|
||||||
|
if got.SpotifyID != "tidal:77616174" {
|
||||||
|
t.Fatalf("unexpected track ID: %q", got.SpotifyID)
|
||||||
|
}
|
||||||
|
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||||
|
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||||
|
}
|
||||||
|
if got.AlbumID != "tidal:77616169" {
|
||||||
|
t.Fatalf("unexpected album ID: %q", got.AlbumID)
|
||||||
|
}
|
||||||
|
if got.ArtistID != "tidal:3852143" {
|
||||||
|
t.Fatalf("unexpected artist ID: %q", got.ArtistID)
|
||||||
|
}
|
||||||
|
if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
|
||||||
|
t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalAlbumToArtistAlbum(t *testing.T) {
|
||||||
|
album := &tidalPublicAlbum{
|
||||||
|
ID: 77616169,
|
||||||
|
Title: "Bruckner: Symphonies 4-9",
|
||||||
|
Type: "ALBUM",
|
||||||
|
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||||
|
ReleaseDate: "2016-02-26",
|
||||||
|
NumberOfTracks: 23,
|
||||||
|
Artists: []tidalPublicArtist{
|
||||||
|
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||||
|
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tidalAlbumToArtistAlbum(album)
|
||||||
|
if got.ID != "tidal:77616169" {
|
||||||
|
t.Fatalf("unexpected album ID: %q", got.ID)
|
||||||
|
}
|
||||||
|
if got.AlbumType != "album" {
|
||||||
|
t.Fatalf("unexpected album type: %q", got.AlbumType)
|
||||||
|
}
|
||||||
|
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||||
|
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||||
|
}
|
||||||
|
if got.Images == "" {
|
||||||
|
t.Fatalf("expected image URL, got empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
|
||||||
|
album := &tidalPublicAlbum{
|
||||||
|
ID: 490623904,
|
||||||
|
Title: "LET 'EM KNOW",
|
||||||
|
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||||
|
NumberOfTracks: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := tidalAlbumToArtistAlbumWithType(album, "single")
|
||||||
|
if got.AlbumType != "single" {
|
||||||
|
t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
title string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{title: "Albums", want: "album"},
|
||||||
|
{title: "EP & Singles", want: "single"},
|
||||||
|
{title: "Compilations", want: "album"},
|
||||||
|
{title: "Appears On", want: "album"},
|
||||||
|
{title: "Unknown", want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
|
||||||
|
t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
|
||||||
|
got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
|
||||||
|
want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("unexpected origin playlist image URL: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTidalPlaylistOwnerName(t *testing.T) {
|
||||||
|
editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
|
||||||
|
if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
|
||||||
|
t.Fatalf("unexpected editorial owner: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
artist := &tidalPublicPlaylist{Type: "ARTIST"}
|
||||||
|
if got := tidalPlaylistOwnerName(artist); got != "Artist" {
|
||||||
|
t.Fatalf("unexpected artist owner: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &tidalPublicPlaylist{}
|
||||||
|
user.Creator.Name = "djtest"
|
||||||
|
if got := tidalPlaylistOwnerName(user); got != "djtest" {
|
||||||
|
t.Fatalf("unexpected creator owner: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||