Compare commits
369 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abc599d7f9 | |||
| 9b27e86e0f | |||
| dbe8f5d814 | |||
| 9847594ca1 | |||
| 986f5eafc8 | |||
| 84df64fcfe | |||
| a9150b85b9 | |||
| 68e6c8be35 | |||
| bd42655c0e | |||
| fe1c96ea12 | |||
| bae2bf63eb | |||
| b6574f0097 | |||
| c35a8dd803 | |||
| d54b2249b6 | |||
| f7be2c1e12 | |||
| ebe7d87da7 | |||
| 3a6b7eed59 | |||
| 51d02d7764 | |||
| df39d61ed4 | |||
| 7ec5d28caf | |||
| 23f5aa11b0 | |||
| 5fdf1df5df | |||
| f9dd82010f | |||
| f0790b627d | |||
| 55350fffa0 | |||
| 7229602343 | |||
| 1c81c53699 | |||
| 5256d6197b | |||
| 79a6c8cdc0 | |||
| aa3b4d7d1e | |||
| cd220a4650 | |||
| d71b2a9ab8 | |||
| a2efe7243d | |||
| e0acda14e4 | |||
| 029ab8ea47 | |||
| 38f9498006 | |||
| 67fc3e5de2 | |||
| f1e6e9253f | |||
| 11c612e270 | |||
| cec5e49659 | |||
| 1dbdb5f2c3 | |||
| 086511d3e9 | |||
| 3d366d21b7 | |||
| 35f412dbd2 | |||
| c167aa0522 | |||
| fccb3f3d78 | |||
| 3a33283e94 | |||
| c74fb28a3a | |||
| ea504cc3ed | |||
| 61a2ad258e | |||
| ab62a8b1a9 | |||
| 479eb1272d | |||
| d23562e579 | |||
| 541d64bdd0 | |||
| d4f7e6e494 | |||
| 532c08fe2e | |||
| 704b9674f4 | |||
| 3de94280d2 | |||
| 65897789f6 | |||
| 5d097c3a95 | |||
| 4023e752a0 | |||
| 9a722b1a24 | |||
| 37b4727a29 | |||
| 2604d0002a | |||
| cca337ab31 | |||
| bb6e766a09 | |||
| af203ae51f | |||
| 01cbdde70e | |||
| e70ed311ed | |||
| c732cddf06 | |||
| 1f71f957e2 | |||
| 757c5fab19 | |||
| cfa537db1f | |||
| 8b18bef5ab | |||
| 76b01fb837 | |||
| 219ea593dd | |||
| 5c54e04b69 | |||
| bef07b1583 | |||
| 859762e35c | |||
| ca136b8e17 | |||
| 03d29a73f7 | |||
| c6ee9cda35 | |||
| ad3fefac0b | |||
| ad606cca53 | |||
| c0a9cb756f | |||
| 5fa00c0051 | |||
| 239e073a8c | |||
| 278ebf3472 | |||
| 7ade57e010 | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| 65a152cada | |||
| e4a6177cb5 | |||
| 34ffbca3e8 | |||
| f8acd8f3b6 | |||
| 9956f051ac | |||
| b33ae905a2 | |||
| 11eb0aa12a | |||
| 7c08321ce3 | |||
| e20becdca7 | |||
| 24897e25e2 | |||
| 2dc4cef583 | |||
| 34c95fbd81 | |||
| 9071db9b88 | |||
| 3eb2fdd7fa | |||
| 99e0d3d361 | |||
| a2eb89e230 | |||
| b21e953ef1 | |||
| 0ef086ce57 | |||
| 72d45746a5 | |||
| 9c22f41a3e | |||
| 22f001a735 | |||
| 26d464d3c7 | |||
| 3d6a3f8d04 | |||
| 39ce22a9e2 | |||
| 88f9a65d11 | |||
| 663ee12bcc | |||
| 8c201b5b4a | |||
| 5e19178bc0 | |||
| 107d9ca007 | |||
| 423695c24d | |||
| 4633c7253a | |||
| 8ace180fa8 | |||
| b9c3f2f0dd | |||
| 81b0eede8c | |||
| eb0cdbeba8 | |||
| ee212a0e48 | |||
| 2073516666 | |||
| 9d479b61d6 | |||
| 203e6bc4eb | |||
| 5f1ffbee4e | |||
| b29dc63337 | |||
| 29699117dc | |||
| 3c75f9ecc6 | |||
| 79340703c1 | |||
| df23e3f96c | |||
| d9f788ddeb | |||
| 62afbdcaaa | |||
| 6c578cfd78 | |||
| a17abec799 | |||
| 2a71b70a34 | |||
| 03f77daf19 | |||
| 270b0c1af6 | |||
| 317bb523a4 | |||
| 2c8ad87b7e | |||
| 5e06729029 | |||
| 21bcfe1157 | |||
| 3aeaaaf4f2 | |||
| 3a9d1395db | |||
| 90c46d99d4 | |||
| 96f44fefd4 | |||
| 38a0a76b69 | |||
| 7fc73b6038 | |||
| 6b61dbc2da | |||
| fd3158fd15 | |||
| ff7135bf2c | |||
| 74bac570c7 | |||
| 5f999035c3 | |||
| fa7b5a3559 | |||
| 187821b2ae | |||
| 1435ba9658 | |||
| 62e2e1703c | |||
| 21a732379b | |||
| 8ac035d146 | |||
| d7e7fb065e | |||
| 11d3b8ab3b | |||
| 566e5996bc | |||
| 51618c7dbd | |||
| bdff3a6135 | |||
| ef7cd4ff5d | |||
| 431e437dee | |||
| cebd43e75a | |||
| 17bfbf95f2 | |||
| dad525be40 | |||
| 7dd0dbd594 | |||
| a0bf423a50 | |||
| 288b060983 | |||
| 5ba60d4fd0 | |||
| 07dae97fe6 | |||
| b210f67728 | |||
| 728d1d58c2 | |||
| 6b9650d451 | |||
| 72ae9072bf | |||
| e82263dc14 | |||
| f03b218775 | |||
| c840b59ae1 | |||
| 1213fc449a | |||
| ca21bb0f0c | |||
| 00555b2df6 | |||
| efca120470 | |||
| a178c3943a | |||
| 01ed1f20ad | |||
| e2bd67083e | |||
| 31fb0a87c9 | |||
| ac4d9fc602 | |||
| 8b1b581dbe | |||
| ebdaa24cfc | |||
| 5633e3adf8 | |||
| fcae5e066d | |||
| c312aea75f | |||
| 1e6e19ecd2 | |||
| 0866b04766 | |||
| 78cef8d58e | |||
| ce84aee8da | |||
| 1ba1665215 | |||
| 60fb18c8e2 | |||
| c042b490b8 | |||
| f544b46d97 | |||
| 70759724fe | |||
| fbfe252df6 | |||
| 2c3def8c7b | |||
| 47e67e8299 | |||
| ec15516230 | |||
| 462013bc2a | |||
| 6b5e53864d | |||
| a8a47589c8 | |||
| b9d567d421 | |||
| 81c77af558 | |||
| 1121680da6 | |||
| d31f2e8894 | |||
| 5895a59cb2 | |||
| 3e5e8d7a42 | |||
| 518a7fd2cf | |||
| 6c832d1754 | |||
| d898b5f23e | |||
| c38a1428f1 | |||
| 759eeccc1f | |||
| d0bc3b203c | |||
| 831b68b6cc | |||
| a06111f445 | |||
| 31fdd30c13 | |||
| e207ef89d5 | |||
| 1261da2e5b | |||
| 0c917bc41e | |||
| f525d6c7e6 | |||
| ed7c67a622 | |||
| 99281df5fb | |||
| 24c2fd6a15 | |||
| ec3fe34dc0 | |||
| 56f36da5f9 | |||
| 9bbd774175 | |||
| 020ac32ee6 | |||
| 67a72210ac | |||
| 020f41fd1e | |||
| 820eb8cc32 | |||
| 47fa5c2009 | |||
| 9b0c929423 | |||
| 93105a45fe | |||
| d8b2f4d367 | |||
| f1478bb2ca | |||
| 8b3c377688 | |||
| 8c98b02dca | |||
| 3743e35e8a | |||
| 05a02de4a9 | |||
| c28378cbb5 | |||
| b2bef63b6b | |||
| 6513e14b21 | |||
| fd53755ad6 | |||
| 1dbacb3027 | |||
| 910d9a7662 | |||
| 09bd8c6b21 | |||
| 908d108858 | |||
| 3135993cf4 | |||
| 7a315b5fd4 | |||
| 4bd6dcc3d7 | |||
| 3f7fa19cdf | |||
| 867ec4d125 | |||
| 164467f3a2 | |||
| fc9a2ddc2a | |||
| 543cb45c11 | |||
| c49e5adc52 | |||
| 0fedd446ca | |||
| 0c7b8a68d9 | |||
| 6dd6accbcc | |||
| ca67f7f79d | |||
| 1aa12c5857 | |||
| 80707fc438 | |||
| ff121dfeb8 | |||
| c3aa6a441b | |||
| 496d32e35b | |||
| 291fa58757 | |||
| eddbc2f986 | |||
| 81b8281d2c | |||
| 57f87d9a4c | |||
| c9d0c57d86 | |||
| 54ab5a9243 | |||
| 17b6b27cd7 | |||
| ed131ca1fd | |||
| 190d65cdee | |||
| dbf2e337f0 | |||
| 12e76bed4f | |||
| e00db80dae | |||
| 5de0aa8145 | |||
| 91ffb25027 | |||
| 6bcbdfedf0 | |||
| 3f42128cb9 | |||
| ccb8f98df5 | |||
| 591a597333 | |||
| 6388f3a5b8 | |||
| 22f52f4af2 | |||
| ceaaff8c9b | |||
| a318495046 | |||
| 8ffc6d3821 | |||
| 2036e46da0 | |||
| b82000e87c | |||
| 144906fd8f | |||
| 8a109e9013 | |||
| ba05f6b470 | |||
| 2f80ae7e84 | |||
| e248fef130 | |||
| 174724ddd3 | |||
| 730945d892 | |||
| 4abdce8c58 | |||
| 55b75dc48d | |||
| f6cea1a683 | |||
| 8d205600b8 | |||
| aa35f60fad | |||
| b627ae1874 | |||
| 46afa6e733 | |||
| c01b189477 | |||
| 966935b677 | |||
| f2f8ca4528 | |||
| 7844bd2f42 | |||
| ac3d51e2cd | |||
| b899b54bb8 | |||
| 7a17de49b2 | |||
| 79180dd918 | |||
| 0d98ada479 | |||
| 5d4fc10ab7 | |||
| e37dfeb080 | |||
| eddae2a9dd | |||
| 6bd7eec615 | |||
| b240e91290 | |||
| 4e0149df29 | |||
| 065872e686 | |||
| 7ab0f5b7c8 | |||
| fd31682242 | |||
| 56c8b62fcf | |||
| c3f879346a | |||
| 6da65ed033 | |||
| 553c6b6c4a | |||
| ac5f74a48f | |||
| e725a7be77 | |||
| 2d22d85c49 | |||
| d960708dac | |||
| c62ad005f5 | |||
| 3edfe8e8bb | |||
| 68fa1bfdae | |||
| 6f9722e05b | |||
| bd6b23400e | |||
| 066d35967e | |||
| 2b932cff70 | |||
| a32487ad88 | |||
| bd4946db37 | |||
| 69f143dd9d | |||
| 15408bfa1c | |||
| edc715021d | |||
| 392472b027 | |||
| 69741fa47c | |||
| 484720bcda | |||
| f3cc51fb06 | |||
| 452ea7084a | |||
| bba059fc44 | |||
| 3f75cace2b | |||
| 556c0e1db2 | |||
| 9897d3102e | |||
| 88dfb88bcc | |||
| 75bfe9b3bf | |||
| f4fe74f972 |
@@ -1 +1,3 @@
|
|||||||
|
github: zarzet
|
||||||
ko_fi: zarzet
|
ko_fi: zarzet
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2 # Need previous commit to compare
|
fetch-depth: 2 # Need previous commit to compare
|
||||||
|
|
||||||
|
|||||||
@@ -60,23 +60,23 @@ jobs:
|
|||||||
df -h
|
df -h
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.21"
|
go-version: "1.25"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||||
@@ -169,17 +169,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.21"
|
go-version: "1.25"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
- name: Cache CocoaPods
|
- name: Cache CocoaPods
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ios/Pods
|
path: ios/Pods
|
||||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||||
@@ -194,7 +194,7 @@ jobs:
|
|||||||
working-directory: go_backend
|
working-directory: go_backend
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ../ios/Frameworks
|
mkdir -p ../ios/Frameworks
|
||||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
|
|
||||||
@@ -249,23 +249,6 @@ jobs:
|
|||||||
channel: "stable"
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
|
||||||
- name: Use iOS pubspec with FFmpeg plugin
|
|
||||||
run: |
|
|
||||||
cp pubspec.yaml pubspec_android_backup.yaml
|
|
||||||
cp pubspec_ios.yaml pubspec.yaml
|
|
||||||
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
|
|
||||||
|
|
||||||
# Swap FFmpeg service for iOS
|
|
||||||
- name: Use iOS FFmpeg service
|
|
||||||
run: |
|
|
||||||
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
|
|
||||||
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
|
|
||||||
# Update class name in the swapped file
|
|
||||||
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
|
|
||||||
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
|
|
||||||
echo "Swapped to iOS FFmpeg service"
|
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -312,7 +295,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||||
@@ -325,7 +308,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Extract changelog for version
|
||||||
id: changelog
|
id: changelog
|
||||||
@@ -355,13 +338,13 @@ jobs:
|
|||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
- name: Download iOS IPA
|
- name: Download iOS IPA
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
@@ -402,7 +385,7 @@ jobs:
|
|||||||
cat /tmp/release_body.txt
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||||
@@ -412,3 +395,135 @@ jobs:
|
|||||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
notify-telegram:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [get-version, create-release]
|
||||||
|
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Download Android APK
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: android-apk
|
||||||
|
path: ./release
|
||||||
|
|
||||||
|
- name: Download iOS IPA
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: ios-ipa
|
||||||
|
path: ./release
|
||||||
|
|
||||||
|
- name: Extract changelog for version
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
VERSION_NUM=${VERSION#v}
|
||||||
|
|
||||||
|
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
|
||||||
|
# Use tr -d '\r' to handle CRLF line endings from Windows
|
||||||
|
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
|
||||||
|
|
||||||
|
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
|
||||||
|
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
|
||||||
|
|
||||||
|
if [ -z "$FULL_CHANGELOG" ]; then
|
||||||
|
CHANGELOG="See release notes on GitHub for details."
|
||||||
|
else
|
||||||
|
# Convert GitHub Markdown to Telegram HTML:
|
||||||
|
# - **text** → <b>text</b>
|
||||||
|
# - `code` → <code>code</code>
|
||||||
|
# - ### Header → <b>Header</b>
|
||||||
|
# - Escape HTML special chars first
|
||||||
|
# - Remove > blockquote prefix
|
||||||
|
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
||||||
|
sed 's/^> //' | \
|
||||||
|
sed 's/&/\&/g' | \
|
||||||
|
sed 's/</\</g' | \
|
||||||
|
sed 's/>/\>/g' | \
|
||||||
|
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
||||||
|
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||||
|
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
|
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
|
sed 's/^- /• /g' | \
|
||||||
|
sed 's/^ - / ◦ /g')
|
||||||
|
|
||||||
|
# Take first 2500 characters, then cut at last complete line
|
||||||
|
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||||
|
|
||||||
|
# Check if truncated
|
||||||
|
FULL_LEN=${#FULL_CHANGELOG}
|
||||||
|
if [ $FULL_LEN -gt 2500 ]; then
|
||||||
|
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
|
echo "DEBUG: Final changelog:"
|
||||||
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
|
- name: Send to Telegram Channel
|
||||||
|
env:
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
CHANGELOG=$(cat /tmp/changelog.txt)
|
||||||
|
|
||||||
|
# Find APK files
|
||||||
|
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
|
||||||
|
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
|
||||||
|
|
||||||
|
# Prepare message with changelog (HTML format)
|
||||||
|
printf '%s\n' \
|
||||||
|
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
|
||||||
|
"" \
|
||||||
|
"<b>What's New:</b>" \
|
||||||
|
"${CHANGELOG}" \
|
||||||
|
"" \
|
||||||
|
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
|
||||||
|
> /tmp/telegram_message.txt
|
||||||
|
|
||||||
|
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||||
|
|
||||||
|
# Send message first (using HTML parse mode)
|
||||||
|
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
|
||||||
|
# Use || true to ensure file uploads continue even if message fails
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
--data-urlencode "text=${MESSAGE}" \
|
||||||
|
--data-urlencode "parse_mode=HTML" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true" || true
|
||||||
|
|
||||||
|
# Upload arm64 APK to channel
|
||||||
|
if [ -f "$ARM64_APK" ]; then
|
||||||
|
echo "Uploading arm64 APK to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
-F document=@"${ARM64_APK}" \
|
||||||
|
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload arm32 APK to channel
|
||||||
|
if [ -f "$ARM32_APK" ]; then
|
||||||
|
echo "Uploading arm32 APK to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
-F document=@"${ARM32_APK}" \
|
||||||
|
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload iOS IPA to channel
|
||||||
|
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
|
||||||
|
if [ -f "$IOS_IPA" ]; then
|
||||||
|
echo "Uploading iOS IPA to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
-F document=@"${IOS_IPA}" \
|
||||||
|
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Telegram notification sent!"
|
||||||
|
|||||||
@@ -72,3 +72,4 @@ flutter_*.log
|
|||||||
|
|
||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
|
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -24,15 +24,6 @@ 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>
|
||||||
|
|
||||||
## Search Source
|
|
||||||
|
|
||||||
SpotiFLAC supports multiple search sources for finding music metadata:
|
|
||||||
|
|
||||||
| Source | Setup |
|
|
||||||
|--------|-------|
|
|
||||||
| **Deezer** (Default) | No setup required |
|
|
||||||
| **Extensions** | Install additional search providers from the Store |
|
|
||||||
|
|
||||||
## 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 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.
|
||||||
@@ -52,6 +43,11 @@ Want to create your own extension? Check out the [Extension Development Guide](h
|
|||||||
### [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 for Windows, macOS & Linux
|
||||||
|
|
||||||
|
## Telegram
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
**Q: Why is my download failing with "Song not found"?**
|
||||||
@@ -69,7 +65,16 @@ A: The app needs permission to save downloaded files to your device. On Android
|
|||||||
**Q: Is this app safe?**
|
**Q: Is this app safe?**
|
||||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
**Q: Why is download not working in my country?**
|
||||||
|
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||||
|
|
||||||
|
|
||||||
|
### Want to support SpotiFLAC-Mobile?
|
||||||
|
|
||||||
|
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||||
|
|
||||||
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
@@ -85,3 +90,18 @@ You are solely responsible for:
|
|||||||
3. Any legal consequences resulting from the misuse of this tool.
|
3. Any legal consequences resulting from the misuse of this tool.
|
||||||
|
|
||||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||||
|
|
||||||
|
|
||||||
|
## API Credits
|
||||||
|
|
||||||
|
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
|
||||||
|
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
|
||||||
|
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
|
||||||
|
- **Lyrics**: [LRCLib](https://lrclib.net)
|
||||||
|
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
|
||||||
|
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||||
|
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||||
|
|||||||
@@ -96,11 +96,13 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
|
|
||||||
// Include all AAR and JAR files from libs folder
|
// Include all AAR and JAR files from libs folder
|
||||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
-keep class io.flutter.view.** { *; }
|
-keep class io.flutter.view.** { *; }
|
||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-keep class io.flutter.plugins.** { *; }
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
-keep class io.flutter.embedding.** { *; }
|
||||||
|
|
||||||
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||||
-dontwarn com.google.android.play.core.splitcompat.**
|
-dontwarn com.google.android.play.core.splitcompat.**
|
||||||
@@ -14,13 +15,22 @@
|
|||||||
# Ignore missing javax.xml.stream (not used on Android)
|
# Ignore missing javax.xml.stream (not used on Android)
|
||||||
-dontwarn javax.xml.stream.**
|
-dontwarn javax.xml.stream.**
|
||||||
|
|
||||||
# Go backend (gobackend.aar)
|
# Go backend (gobackend.aar) - CRITICAL for release builds
|
||||||
-keep class gobackend.** { *; }
|
-keep class gobackend.** { *; }
|
||||||
-keep class go.** { *; }
|
-keep class go.** { *; }
|
||||||
|
-keep interface gobackend.** { *; }
|
||||||
|
-keepclassmembers class gobackend.** { *; }
|
||||||
|
|
||||||
|
# Go mobile binding internals
|
||||||
|
-keep class org.golang.** { *; }
|
||||||
|
-dontwarn org.golang.**
|
||||||
|
|
||||||
# FFmpeg Kit
|
# FFmpeg Kit
|
||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-keep class com.arthenica.smartexception.** { *; }
|
||||||
|
# FFmpeg Kit (new fork package)
|
||||||
|
-keep class com.antonkarpenko.ffmpegkit.** { *; }
|
||||||
|
-keep class com.antonkarpenko.smartexception.** { *; }
|
||||||
|
|
||||||
# Apache Tika (if used by FFmpeg)
|
# Apache Tika (if used by FFmpeg)
|
||||||
-dontwarn org.apache.tika.**
|
-dontwarn org.apache.tika.**
|
||||||
@@ -30,15 +40,77 @@
|
|||||||
native <methods>;
|
native <methods>;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Kotlin coroutines
|
# Kotlin coroutines - expanded rules
|
||||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||||
-keepclassmembers class kotlinx.coroutines.** {
|
-keepclassmembers class kotlinx.coroutines.** {
|
||||||
volatile <fields>;
|
volatile <fields>;
|
||||||
}
|
}
|
||||||
|
-keepclassmembernames class kotlinx.** {
|
||||||
|
volatile <fields>;
|
||||||
|
}
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
|
||||||
|
# Kotlin serialization
|
||||||
|
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||||
|
-dontwarn kotlin.**
|
||||||
|
-keep class kotlin.** { *; }
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
|
||||||
|
# Keep MainActivity and related classes
|
||||||
|
-keep class com.zarz.spotiflac.** { *; }
|
||||||
|
|
||||||
# Prevent R8 from removing metadata
|
# Prevent R8 from removing metadata
|
||||||
-keepattributes *Annotation*
|
-keepattributes *Annotation*
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
-keepattributes Signature
|
-keepattributes Signature
|
||||||
-keepattributes Exceptions
|
-keepattributes Exceptions
|
||||||
|
-keepattributes InnerClasses
|
||||||
|
-keepattributes EnclosingMethod
|
||||||
|
|
||||||
|
# JSON parsing (used by Go backend responses)
|
||||||
|
-keep class org.json.** { *; }
|
||||||
|
|
||||||
|
# Shared Preferences
|
||||||
|
-keep class androidx.datastore.** { *; }
|
||||||
|
-dontwarn androidx.datastore.**
|
||||||
|
|
||||||
|
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
|
||||||
|
# Path Provider
|
||||||
|
-keep class io.flutter.plugins.pathprovider.** { *; }
|
||||||
|
-keep class dev.flutter.pigeon.** { *; }
|
||||||
|
|
||||||
|
# Local Notifications
|
||||||
|
-keep class com.dexterous.** { *; }
|
||||||
|
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||||
|
|
||||||
|
# Receive Sharing Intent
|
||||||
|
-keep class com.kasem.receive_sharing_intent.** { *; }
|
||||||
|
|
||||||
|
# Permission Handler
|
||||||
|
-keep class com.baseflow.permissionhandler.** { *; }
|
||||||
|
|
||||||
|
# File Picker
|
||||||
|
-keep class com.mr.flutter.plugin.filepicker.** { *; }
|
||||||
|
|
||||||
|
# URL Launcher
|
||||||
|
-keep class io.flutter.plugins.urllauncher.** { *; }
|
||||||
|
|
||||||
|
# Share Plus
|
||||||
|
-keep class dev.fluttercommunity.plus.share.** { *; }
|
||||||
|
|
||||||
|
# Device Info Plus
|
||||||
|
-keep class dev.fluttercommunity.plus.device_info.** { *; }
|
||||||
|
|
||||||
|
# Open File
|
||||||
|
-keep class com.crazecoder.openfile.** { *; }
|
||||||
|
|
||||||
|
# Sqflite
|
||||||
|
-keep class com.tekartik.sqflite.** { *; }
|
||||||
|
|
||||||
|
# Dynamic Color
|
||||||
|
-keep class io.material.** { *; }
|
||||||
|
|
||||||
|
# Keep all Flutter plugin registrants
|
||||||
|
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
|
||||||
|
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
android:label="SpotiFLAC"
|
android:label="SpotiFLAC"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:usesCleartextTraffic="false"
|
||||||
android:usesCleartextTraffic="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:enableOnBackInvokedCallback="true">
|
android:localeConfig="@xml/locale_config">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Handle Spotify URL sharing -->
|
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
@@ -57,6 +57,33 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="https" android:host="open.spotify.com" />
|
<data android:scheme="https" android:host="open.spotify.com" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle Deezer deep links -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="www.deezer.com" />
|
||||||
|
<data android:scheme="https" android:host="deezer.com" />
|
||||||
|
<data android:scheme="https" android:host="deezer.page.link" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle Tidal deep links -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="tidal.com" />
|
||||||
|
<data android:scheme="https" android:host="listen.tidal.com" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle YouTube Music deep links -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="music.youtube.com" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Download Service -->
|
<!-- Download Service -->
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<locale android:name="en" />
|
||||||
|
<locale android:name="ru" />
|
||||||
|
<locale android:name="es-ES" />
|
||||||
|
<locale android:name="id" />
|
||||||
|
<locale android:name="pt-PT" />
|
||||||
|
<locale android:name="ja" />
|
||||||
|
<locale android:name="tr" />
|
||||||
|
<locale android:name="de" />
|
||||||
|
<locale android:name="fr" />
|
||||||
|
<locale android:name="hi" />
|
||||||
|
<locale android:name="ko" />
|
||||||
|
<locale android:name="nl" />
|
||||||
|
<locale android:name="zh" />
|
||||||
|
</locale-config>
|
||||||
@@ -22,7 +22,7 @@ subprojects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add desugaring dependency to all Android subprojects
|
// Add desugaring dependency to all Android subprojects
|
||||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 81 KiB |
@@ -1,335 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
|
||||||
|
|
||||||
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
|
||||||
class FFmpegServiceIOS {
|
|
||||||
/// Execute FFmpeg command and return result
|
|
||||||
static Future<FFmpegResultIOS> _execute(String command) async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute(command);
|
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
final output = await session.getOutput() ?? '';
|
|
||||||
return FFmpegResultIOS(
|
|
||||||
success: ReturnCode.isSuccess(returnCode),
|
|
||||||
returnCode: returnCode?.getValue() ?? -1,
|
|
||||||
output: output,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('FFmpeg execute error: $e');
|
|
||||||
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert M4A (DASH segments) to FLAC
|
|
||||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
|
||||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
|
||||||
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(inputPath).delete();
|
|
||||||
} catch (_) {}
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC to MP3
|
|
||||||
/// If deleteOriginal is true, deletes the FLAC file after conversion
|
|
||||||
static Future<String?> convertFlacToMp3(
|
|
||||||
String inputPath, {
|
|
||||||
String bitrate = '320k',
|
|
||||||
bool deleteOriginal = true,
|
|
||||||
}) async {
|
|
||||||
// Convert in same folder, just change extension
|
|
||||||
final outputPath = inputPath.replaceAll('.flac', '.mp3');
|
|
||||||
|
|
||||||
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Delete original FLAC if requested
|
|
||||||
if (deleteOriginal) {
|
|
||||||
try {
|
|
||||||
await File(inputPath).delete();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC to M4A
|
|
||||||
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
|
||||||
final dir = File(inputPath).parent.path;
|
|
||||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
|
||||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
|
||||||
await Directory(outputDir).create(recursive: true);
|
|
||||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
|
||||||
|
|
||||||
String command;
|
|
||||||
if (codec == 'alac') {
|
|
||||||
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
|
||||||
} else {
|
|
||||||
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
if (result.success) return outputPath;
|
|
||||||
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed cover art to FLAC file
|
|
||||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
|
||||||
final tempOutput = '$flacPath.tmp';
|
|
||||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
return flacPath;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace file after cover embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) await tempFile.delete();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed metadata and cover art to FLAC file
|
|
||||||
/// Returns the file path on success, null on failure
|
|
||||||
static Future<String?> embedMetadata({
|
|
||||||
required String flacPath,
|
|
||||||
String? coverPath,
|
|
||||||
Map<String, String>? metadata,
|
|
||||||
}) async {
|
|
||||||
final tempOutput = '$flacPath.tmp';
|
|
||||||
|
|
||||||
// Construct command
|
|
||||||
final StringBuffer cmdBuffer = StringBuffer();
|
|
||||||
cmdBuffer.write('-i "$flacPath" ');
|
|
||||||
|
|
||||||
// Add cover input if available
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-i "$coverPath" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map audio stream
|
|
||||||
cmdBuffer.write('-map 0:a ');
|
|
||||||
|
|
||||||
// Map cover stream if available
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-map 1:0 ');
|
|
||||||
cmdBuffer.write('-c:v copy ');
|
|
||||||
cmdBuffer.write('-disposition:v attached_pic ');
|
|
||||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
|
||||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy audio codec (don't re-encode)
|
|
||||||
cmdBuffer.write('-c:a copy ');
|
|
||||||
|
|
||||||
// Add text metadata
|
|
||||||
if (metadata != null) {
|
|
||||||
metadata.forEach((key, value) {
|
|
||||||
// Sanitize value: escape double quotes
|
|
||||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
|
||||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('"$tempOutput" -y');
|
|
||||||
|
|
||||||
final command = cmdBuffer.toString();
|
|
||||||
_log.d('Executing FFmpeg command: $command');
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
return flacPath;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace file after metadata embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up temp file if exists
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) {
|
|
||||||
await tempFile.delete();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('Metadata/Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed metadata and cover art to MP3 file using ID3v2 tags
|
|
||||||
/// Returns the file path on success, null on failure
|
|
||||||
static Future<String?> embedMetadataToMp3({
|
|
||||||
required String mp3Path,
|
|
||||||
String? coverPath,
|
|
||||||
Map<String, String>? metadata,
|
|
||||||
}) async {
|
|
||||||
final tempOutput = '$mp3Path.tmp';
|
|
||||||
|
|
||||||
final StringBuffer cmdBuffer = StringBuffer();
|
|
||||||
cmdBuffer.write('-i "$mp3Path" ');
|
|
||||||
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-i "$coverPath" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('-map 0:a ');
|
|
||||||
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-map 1:0 ');
|
|
||||||
cmdBuffer.write('-c:v:0 copy ');
|
|
||||||
cmdBuffer.write('-id3v2_version 3 ');
|
|
||||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
|
||||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('-c:a copy ');
|
|
||||||
|
|
||||||
if (metadata != null) {
|
|
||||||
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
|
|
||||||
final id3Metadata = _convertToId3Tags(metadata);
|
|
||||||
id3Metadata.forEach((key, value) {
|
|
||||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
|
||||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
|
|
||||||
|
|
||||||
final command = cmdBuffer.toString();
|
|
||||||
_log.d('Executing FFmpeg MP3 embed command: $command');
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(mp3Path).delete();
|
|
||||||
await File(tempOutput).rename(mp3Path);
|
|
||||||
_log.d('MP3 metadata embedded successfully');
|
|
||||||
return mp3Path;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace MP3 file after metadata embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) {
|
|
||||||
await tempFile.delete();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
|
|
||||||
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
|
||||||
final id3Map = <String, String>{};
|
|
||||||
|
|
||||||
for (final entry in vorbisMetadata.entries) {
|
|
||||||
final key = entry.key.toUpperCase();
|
|
||||||
final value = entry.value;
|
|
||||||
|
|
||||||
// Map Vorbis comments to ID3v2 frame names
|
|
||||||
switch (key) {
|
|
||||||
case 'TITLE':
|
|
||||||
id3Map['title'] = value;
|
|
||||||
break;
|
|
||||||
case 'ARTIST':
|
|
||||||
id3Map['artist'] = value;
|
|
||||||
break;
|
|
||||||
case 'ALBUM':
|
|
||||||
id3Map['album'] = value;
|
|
||||||
break;
|
|
||||||
case 'ALBUMARTIST':
|
|
||||||
id3Map['album_artist'] = value;
|
|
||||||
break;
|
|
||||||
case 'TRACKNUMBER':
|
|
||||||
case 'TRACK':
|
|
||||||
id3Map['track'] = value;
|
|
||||||
break;
|
|
||||||
case 'DISCNUMBER':
|
|
||||||
case 'DISC':
|
|
||||||
id3Map['disc'] = value;
|
|
||||||
break;
|
|
||||||
case 'DATE':
|
|
||||||
case 'YEAR':
|
|
||||||
id3Map['date'] = value;
|
|
||||||
break;
|
|
||||||
case 'ISRC':
|
|
||||||
id3Map['TSRC'] = value; // ID3v2 ISRC frame
|
|
||||||
break;
|
|
||||||
case 'LYRICS':
|
|
||||||
case 'UNSYNCEDLYRICS':
|
|
||||||
id3Map['lyrics'] = value;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Pass through other tags as-is
|
|
||||||
id3Map[key.toLowerCase()] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return id3Map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
|
||||||
static Future<bool> isAvailable() async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute('-version');
|
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
return ReturnCode.isSuccess(returnCode);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get FFmpeg version info
|
|
||||||
static Future<String?> getVersion() async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute('-version');
|
|
||||||
return await session.getOutput();
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FFmpegResultIOS {
|
|
||||||
final bool success;
|
|
||||||
final int returnCode;
|
|
||||||
final String output;
|
|
||||||
|
|
||||||
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractAmazonASIN(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "prefers trackAsin over albumAsin",
|
||||||
|
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
||||||
|
want: "B0TRACK456",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract from tracks path",
|
||||||
|
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract from plain query asin",
|
||||||
|
url: "https://example.com/?asin=B0CYQHGWZJ",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback regex",
|
||||||
|
url: "https://example.com/path/B0CYQHGWZJ",
|
||||||
|
want: "B0CYQHGWZJ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid url",
|
||||||
|
url: "https://music.amazon.com/tracks/not-valid",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := extractAmazonASIN(tt.url)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,15 +23,28 @@ const (
|
|||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
deezerMaxParallelISRC = 10
|
deezerMaxParallelISRC = 10
|
||||||
|
|
||||||
|
// Deezer API timeout and retry configuration for mobile networks
|
||||||
|
deezerAPITimeoutMobile = 25 * time.Second
|
||||||
|
deezerMaxRetries = 2
|
||||||
|
deezerRetryDelay = 500 * time.Millisecond
|
||||||
|
|
||||||
|
deezerMaxSearchCacheEntries = 300
|
||||||
|
deezerMaxAlbumCacheEntries = 200
|
||||||
|
deezerMaxArtistCacheEntries = 200
|
||||||
|
deezerMaxISRCCacheEntries = 4000
|
||||||
|
deezerCacheCleanupInterval = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeezerClient struct {
|
type DeezerClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
searchCache map[string]*cacheEntry
|
searchCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry
|
albumCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry
|
artistCache map[string]*cacheEntry
|
||||||
isrcCache map[string]string
|
isrcCache map[string]string
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
|
lastCacheCleanup time.Time
|
||||||
|
cacheCleanupInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -42,20 +55,115 @@ var (
|
|||||||
func GetDeezerClient() *DeezerClient {
|
func GetDeezerClient() *DeezerClient {
|
||||||
deezerClientOnce.Do(func() {
|
deezerClientOnce.Do(func() {
|
||||||
deezerClient = &DeezerClient{
|
deezerClient = &DeezerClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
|
||||||
searchCache: make(map[string]*cacheEntry),
|
searchCache: make(map[string]*cacheEntry),
|
||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
artistCache: make(map[string]*cacheEntry),
|
artistCache: make(map[string]*cacheEntry),
|
||||||
isrcCache: make(map[string]string),
|
isrcCache: make(map[string]string),
|
||||||
|
cacheCleanupInterval: deezerCacheCleanupInterval,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return deezerClient
|
return deezerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
|
||||||
|
cache map[string]*cacheEntry,
|
||||||
|
now time.Time,
|
||||||
|
) {
|
||||||
|
for key, entry := range cache {
|
||||||
|
if entry == nil || now.After(entry.expiresAt) {
|
||||||
|
delete(cache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) trimCacheEntriesLocked(
|
||||||
|
cache map[string]*cacheEntry,
|
||||||
|
maxEntries int,
|
||||||
|
) {
|
||||||
|
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for len(cache) > maxEntries {
|
||||||
|
var oldestKey string
|
||||||
|
var oldestExpiry time.Time
|
||||||
|
first := true
|
||||||
|
for key, entry := range cache {
|
||||||
|
expiry := time.Time{}
|
||||||
|
if entry != nil {
|
||||||
|
expiry = entry.expiresAt
|
||||||
|
}
|
||||||
|
if first || expiry.Before(oldestExpiry) {
|
||||||
|
first = false
|
||||||
|
oldestKey = key
|
||||||
|
oldestExpiry = expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(cache, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) trimStringCacheEntriesLocked(
|
||||||
|
cache map[string]string,
|
||||||
|
maxEntries int,
|
||||||
|
) {
|
||||||
|
if maxEntries <= 0 || len(cache) <= maxEntries {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toRemove := len(cache) - maxEntries
|
||||||
|
for key := range cache {
|
||||||
|
delete(cache, key)
|
||||||
|
toRemove--
|
||||||
|
if toRemove <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
|
||||||
|
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
|
||||||
|
(c.lastCacheCleanup.IsZero() ||
|
||||||
|
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
|
||||||
|
|
||||||
|
if periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||||
|
c.lastCacheCleanup = now
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.searchCache) > deezerMaxSearchCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.artistCache) > deezerMaxArtistCacheEntries {
|
||||||
|
if !periodicCleanupDue {
|
||||||
|
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
|
||||||
|
}
|
||||||
|
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
|
||||||
|
}
|
||||||
|
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
|
||||||
|
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Duration int `json:"duration"` // in seconds
|
Duration int `json:"duration"`
|
||||||
TrackPosition int `json:"track_position"`
|
TrackPosition int `json:"track_position"`
|
||||||
DiskNumber int `json:"disk_number"`
|
DiskNumber int `json:"disk_number"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
@@ -121,7 +229,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
AlbumArtist: track.Artist.Name,
|
AlbumArtist: track.Artist.Name,
|
||||||
DurationMS: track.Duration * 1000,
|
DurationMS: track.Duration * 1000,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: releaseDate, // Added this
|
ReleaseDate: releaseDate,
|
||||||
TrackNumber: track.TrackPosition,
|
TrackNumber: track.TrackPosition,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
@@ -182,11 +290,38 @@ type deezerPlaylistFull struct {
|
|||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
albumLimit := 5
|
||||||
|
playlistLimit := 5
|
||||||
|
|
||||||
|
if filter != "" {
|
||||||
|
switch filter {
|
||||||
|
case "track":
|
||||||
|
trackLimit = 50
|
||||||
|
artistLimit = 0
|
||||||
|
albumLimit = 0
|
||||||
|
playlistLimit = 0
|
||||||
|
case "artist":
|
||||||
|
trackLimit = 0
|
||||||
|
artistLimit = 20
|
||||||
|
albumLimit = 0
|
||||||
|
playlistLimit = 0
|
||||||
|
case "album":
|
||||||
|
trackLimit = 0
|
||||||
|
artistLimit = 0
|
||||||
|
albumLimit = 20
|
||||||
|
playlistLimit = 0
|
||||||
|
case "playlist":
|
||||||
|
trackLimit = 0
|
||||||
|
artistLimit = 0
|
||||||
|
albumLimit = 0
|
||||||
|
playlistLimit = 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d:%d:%d:%s", query, trackLimit, artistLimit, albumLimit, playlistLimit, filter)
|
||||||
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
@@ -197,81 +332,202 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
result := &SearchAllResult{
|
result := &SearchAllResult{
|
||||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||||
|
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||||
|
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search tracks - NO ISRC fetch for performance
|
if trackLimit > 0 {
|
||||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||||
|
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
Error *struct {
|
Error *struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
GoLog("[Deezer] Track search failed: %v\n", err)
|
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if trackResp.Error != nil {
|
if trackResp.Error != nil {
|
||||||
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||||
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||||
|
|
||||||
for _, track := range trackResp.Data {
|
for _, track := range trackResp.Data {
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
}
|
|
||||||
|
|
||||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
|
||||||
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
|
||||||
|
|
||||||
var artistResp struct {
|
|
||||||
Data []deezerArtist `json:"data"`
|
|
||||||
Error *struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code int `json:"code"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
|
||||||
if artistResp.Error != nil {
|
|
||||||
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
|
||||||
for _, artist := range artistResp.Data {
|
|
||||||
result.Artists = append(result.Artists, SearchArtistResult{
|
|
||||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
|
||||||
Name: artist.Name,
|
|
||||||
Images: c.getBestArtistImage(artist),
|
|
||||||
Followers: artist.NbFan,
|
|
||||||
Popularity: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] Artist search failed: %v\n", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
if artistLimit > 0 {
|
||||||
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
|
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||||
|
|
||||||
|
var artistResp struct {
|
||||||
|
Data []deezerArtist `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
|
if artistResp.Error != nil {
|
||||||
|
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||||
|
for _, artist := range artistResp.Data {
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImage(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if albumLimit > 0 {
|
||||||
|
albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit)
|
||||||
|
GoLog("[Deezer] Fetching albums from: %s\n", albumURL)
|
||||||
|
|
||||||
|
var albumResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
RecordType string `json:"record_type"`
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, albumURL, &albumResp); err == nil {
|
||||||
|
if albumResp.Error != nil {
|
||||||
|
GoLog("[Deezer] Album API error: type=%s, code=%d, message=%s\n", albumResp.Error.Type, albumResp.Error.Code, albumResp.Error.Message)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Got %d albums from API\n", len(albumResp.Data))
|
||||||
|
for _, album := range albumResp.Data {
|
||||||
|
coverURL := album.CoverXL
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverBig
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverMedium
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
albumType := album.RecordType
|
||||||
|
if albumType == "compile" {
|
||||||
|
albumType = "compilation"
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Albums = append(result.Albums, SearchAlbumResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
Name: album.Title,
|
||||||
|
Artists: album.Artist.Name,
|
||||||
|
Images: coverURL,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
AlbumType: albumType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Album search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if playlistLimit > 0 {
|
||||||
|
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
|
||||||
|
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
|
||||||
|
|
||||||
|
var playlistResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
User struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"user"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, playlistURL, &playlistResp); err == nil {
|
||||||
|
if playlistResp.Error != nil {
|
||||||
|
GoLog("[Deezer] Playlist API error: type=%s, code=%d, message=%s\n", playlistResp.Error.Type, playlistResp.Error.Code, playlistResp.Error.Message)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Got %d playlists from API\n", len(playlistResp.Data))
|
||||||
|
for _, playlist := range playlistResp.Data {
|
||||||
|
pictureURL := playlist.PictureXL
|
||||||
|
if pictureURL == "" {
|
||||||
|
pictureURL = playlist.PictureBig
|
||||||
|
}
|
||||||
|
if pictureURL == "" {
|
||||||
|
pictureURL = playlist.PictureMedium
|
||||||
|
}
|
||||||
|
if pictureURL == "" {
|
||||||
|
pictureURL = playlist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Playlists = append(result.Playlists, SearchPlaylistResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", playlist.ID),
|
||||||
|
Name: playlist.Title,
|
||||||
|
Owner: playlist.User.Name,
|
||||||
|
Images: pictureURL,
|
||||||
|
TotalTracks: playlist.NbTracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Playlist search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrack fetches a single track by Deezer ID
|
|
||||||
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
@@ -285,7 +541,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC is fetched in parallel for better performance
|
|
||||||
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
@@ -311,7 +566,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
artistName = strings.Join(names, ", ")
|
artistName = strings.Join(names, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract genres as comma-separated string
|
|
||||||
var genres []string
|
var genres []string
|
||||||
for _, g := range album.Genres.Data {
|
for _, g := range album.Genres.Data {
|
||||||
if g.Name != "" {
|
if g.Name != "" {
|
||||||
@@ -325,24 +579,62 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
Name: album.Title,
|
Name: album.Title,
|
||||||
ReleaseDate: album.ReleaseDate,
|
ReleaseDate: album.ReleaseDate,
|
||||||
Artists: artistName,
|
Artists: artistName,
|
||||||
|
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
Genre: genreStr, // From Deezer album
|
Genre: genreStr,
|
||||||
Label: album.Label, // From Deezer album
|
Label: album.Label,
|
||||||
}
|
}
|
||||||
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
allTracks := album.Tracks.Data
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
if album.NbTracks > len(allTracks) {
|
||||||
// Normalize record_type (Deezer uses "compile" instead of "compilation")
|
GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks))
|
||||||
|
|
||||||
|
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerAlbumURL, albumID), len(allTracks))
|
||||||
|
|
||||||
|
for len(allTracks) < album.NbTracks {
|
||||||
|
var tracksResp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
||||||
|
GoLog("[Deezer] Warning: failed to fetch album tracks page: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tracksResp.Data) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allTracks = append(allTracks, tracksResp.Data...)
|
||||||
|
|
||||||
|
if tracksResp.Next == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracksURL = tracksResp.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Fetched total %d tracks for album", len(allTracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
albumType := album.RecordType
|
albumType := album.RecordType
|
||||||
if albumType == "compile" {
|
if albumType == "compile" {
|
||||||
albumType = "compilation"
|
albumType = "compilation"
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, track := range album.Tracks.Data {
|
for i, track := range allTracks {
|
||||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
isrc := isrcMap[trackIDStr]
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
|
trackNum := track.TrackPosition
|
||||||
|
if trackNum == 0 {
|
||||||
|
trackNum = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
Artists: track.Artist.Name,
|
Artists: track.Artist.Name,
|
||||||
@@ -352,7 +644,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
DurationMS: track.Duration * 1000,
|
DurationMS: track.Duration * 1000,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: album.ReleaseDate,
|
ReleaseDate: album.ReleaseDate,
|
||||||
TrackNumber: track.TrackPosition,
|
TrackNumber: trackNum,
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
@@ -368,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.albumCache[albumID] = &cacheEntry{
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -385,7 +679,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Fetch artist info
|
|
||||||
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
||||||
var artist deezerArtistFull
|
var artist deezerArtistFull
|
||||||
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
||||||
@@ -400,7 +693,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
Popularity: 0,
|
Popularity: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch artist albums
|
|
||||||
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
||||||
var albumsResp struct {
|
var albumsResp struct {
|
||||||
Data []struct {
|
Data []struct {
|
||||||
@@ -412,7 +704,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
RecordType string `json:"record_type"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.artistCache[artistID] = &cacheEntry{
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -484,10 +778,43 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
info.Owner.Name = playlist.Title
|
info.Owner.Name = playlist.Title
|
||||||
info.Owner.Images = playlistImage
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
allTracks := playlist.Tracks.Data
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
if playlist.NbTracks > len(allTracks) {
|
||||||
for _, track := range playlist.Tracks.Data {
|
GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks))
|
||||||
|
|
||||||
|
tracksURL := fmt.Sprintf("%s/tracks?limit=100&index=%d", fmt.Sprintf(deezerPlaylistURL, playlistID), len(allTracks))
|
||||||
|
|
||||||
|
for len(allTracks) < playlist.NbTracks {
|
||||||
|
var tracksResp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, tracksURL, &tracksResp); err != nil {
|
||||||
|
GoLog("[Deezer] Warning: failed to fetch playlist tracks page: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tracksResp.Data) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allTracks = append(allTracks, tracksResp.Data...)
|
||||||
|
|
||||||
|
if tracksResp.Next == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracksURL = tracksResp.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Fetched total %d tracks for playlist", len(allTracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
|
for _, track := range allTracks {
|
||||||
albumImage := track.Album.CoverXL
|
albumImage := track.Album.CoverXL
|
||||||
if albumImage == "" {
|
if albumImage == "" {
|
||||||
albumImage = track.Album.CoverBig
|
albumImage = track.Album.CoverBig
|
||||||
@@ -558,7 +885,6 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
|||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
|
||||||
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||||
result := make(map[string]string, len(tracks))
|
result := make(map[string]string, len(tracks))
|
||||||
var resultMu sync.Mutex
|
var resultMu sync.Mutex
|
||||||
@@ -590,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
for trackIDStr, isrc := range directISRCs {
|
for trackIDStr, isrc := range directISRCs {
|
||||||
c.isrcCache[trackIDStr] = isrc
|
c.isrcCache[trackIDStr] = isrc
|
||||||
}
|
}
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +924,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use semaphore to limit concurrent requests
|
|
||||||
sem := make(chan struct{}, deezerMaxParallelISRC)
|
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
@@ -619,13 +945,13 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in result and cache
|
|
||||||
resultMu.Lock()
|
resultMu.Lock()
|
||||||
result[trackIDStr] = fullTrack.ISRC
|
result[trackIDStr] = fullTrack.ISRC
|
||||||
resultMu.Unlock()
|
resultMu.Unlock()
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}(track)
|
}(track)
|
||||||
}
|
}
|
||||||
@@ -634,7 +960,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use this when you need ISRC for download
|
|
||||||
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if isrc, ok := c.isrcCache[trackID]; ok {
|
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||||
@@ -650,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackID] = fullTrack.ISRC
|
c.isrcCache[trackID] = fullTrack.ISRC
|
||||||
|
c.maybeCleanupCachesLocked(time.Now())
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
return fullTrack.ISRC, nil
|
return fullTrack.ISRC, nil
|
||||||
@@ -695,11 +1021,10 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlbumExtendedMetadata struct {
|
type AlbumExtendedMetadata struct {
|
||||||
Genre string // Comma-separated list of genres
|
Genre string
|
||||||
Label string // Record label name
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uses the album ID from a track to fetch extended metadata
|
|
||||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||||
if albumID == "" {
|
if albumID == "" {
|
||||||
return nil, fmt.Errorf("empty album ID")
|
return nil, fmt.Errorf("empty album ID")
|
||||||
@@ -733,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
expiresAt: time.Now().Add(deezerCacheTTL),
|
expiresAt: now.Add(deezerCacheTTL),
|
||||||
}
|
}
|
||||||
|
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\n", result.Genre, result.Label)
|
||||||
@@ -744,7 +1071,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrackAlbumID fetches the album ID for a Deezer track
|
|
||||||
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
|
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
|
||||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
@@ -756,7 +1082,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str
|
|||||||
return fmt.Sprintf("%d", track.Album.ID), nil
|
return fmt.Sprintf("%d", track.Album.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a convenience function that first gets the album ID, then fetches album metadata
|
|
||||||
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
|
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
|
||||||
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -766,33 +1091,62 @@ func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID
|
|||||||
return c.GetAlbumExtendedMetadata(ctx, albumID)
|
return c.GetAlbumExtendedMetadata(ctx, albumID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
|
|
||||||
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
return nil, fmt.Errorf("empty ISRC")
|
return nil, fmt.Errorf("empty ISRC")
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, search for track by ISRC
|
|
||||||
track, err := c.SearchByISRC(ctx, isrc)
|
track, err := c.SearchByISRC(ctx, isrc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
|
return nil, fmt.Errorf("failed to find track by ISRC: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpotifyID contains "deezer:123" format, extract the ID
|
deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:")
|
||||||
deezerID := track.SpotifyID
|
|
||||||
if strings.HasPrefix(deezerID, "deezer:") {
|
|
||||||
deezerID = strings.TrimPrefix(deezerID, "deezer:")
|
|
||||||
}
|
|
||||||
|
|
||||||
if deezerID == "" {
|
if deezerID == "" {
|
||||||
return nil, fmt.Errorf("track found but no Deezer ID")
|
return nil, fmt.Errorf("track found but no Deezer ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then fetch extended metadata using the Deezer track ID
|
|
||||||
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||||
|
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.doGetJSON(ctx, endpoint, dst)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
errStr := err.Error()
|
||||||
|
|
||||||
|
// Check if error is retryable
|
||||||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
strings.Contains(errStr, "EOF") ||
|
||||||
|
strings.Contains(errStr, "status 5") ||
|
||||||
|
strings.Contains(errStr, "status 429")
|
||||||
|
|
||||||
|
if !isRetryable {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -818,7 +1172,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
return json.Unmarshal(body, dst)
|
return json.Unmarshal(body, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDeezerURL is internal function, returns type and ID
|
|
||||||
func parseDeezerURL(input string) (string, string, error) {
|
func parseDeezerURL(input string) (string, string, error) {
|
||||||
trimmed := strings.TrimSpace(input)
|
trimmed := strings.TrimSpace(input)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
|
|
||||||
type ISRCIndex struct {
|
type ISRCIndex struct {
|
||||||
index map[string]string // ISRC (uppercase) -> file path
|
index map[string]string // ISRC (uppercase) -> file path
|
||||||
outputDir string
|
outputDir string
|
||||||
@@ -25,8 +24,6 @@ var (
|
|||||||
isrcIndexTTL = 5 * time.Minute
|
isrcIndexTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetISRCIndex returns or builds an ISRC index for the given directory
|
|
||||||
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
|
|
||||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
// Fast path: check cache first
|
// Fast path: check cache first
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
@@ -56,7 +53,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return buildISRCIndex(outputDir)
|
return buildISRCIndex(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
|
||||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
idx := &ISRCIndex{
|
idx := &ISRCIndex{
|
||||||
index: make(map[string]string),
|
index: make(map[string]string),
|
||||||
@@ -91,7 +87,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
@@ -113,7 +109,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
|||||||
return path, exists
|
return path, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove deletes an ISRC entry from the index (internal use)
|
|
||||||
func (idx *ISRCIndex) remove(isrc string) {
|
func (idx *ISRCIndex) remove(isrc string) {
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
return
|
return
|
||||||
@@ -125,14 +120,11 @@ func (idx *ISRCIndex) remove(isrc string) {
|
|||||||
delete(idx.index, strings.ToUpper(isrc))
|
delete(idx.index, strings.ToUpper(isrc))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
|
||||||
// Returns filepath if found, empty string if not found
|
|
||||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
path, _ := idx.lookup(isrc)
|
path, _ := idx.lookup(isrc)
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds a new ISRC to the index (call after successful download)
|
|
||||||
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||||
if isrc == "" || filePath == "" {
|
if isrc == "" || filePath == "" {
|
||||||
return
|
return
|
||||||
@@ -144,15 +136,12 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
|
|||||||
idx.index[strings.ToUpper(isrc)] = filePath
|
idx.index[strings.ToUpper(isrc)] = filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateCache clears the ISRC index cache for a directory
|
|
||||||
func InvalidateISRCCache(outputDir string) {
|
func InvalidateISRCCache(outputDir string) {
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
delete(isrcIndexCache, outputDir)
|
delete(isrcIndexCache, outputDir)
|
||||||
isrcIndexCacheMu.Unlock()
|
isrcIndexCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
|
||||||
// Uses ISRC index for fast lookup
|
|
||||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||||
if isrc == "" || outputDir == "" {
|
if isrc == "" || outputDir == "" {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -173,13 +162,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|||||||
return filePath, true
|
return filePath, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
|
||||||
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||||
return filepath, nil
|
return filepath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckFileExists checks if a file with the given name exists
|
|
||||||
func CheckFileExists(filePath string) bool {
|
func CheckFileExists(filePath string) bool {
|
||||||
info, err := os.Stat(filePath)
|
info, err := os.Stat(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -188,7 +175,6 @@ func CheckFileExists(filePath string) bool {
|
|||||||
return !info.IsDir() && info.Size() > 0
|
return !info.IsDir() && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileExistenceResult represents the result of checking if a file exists
|
|
||||||
type FileExistenceResult struct {
|
type FileExistenceResult struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
@@ -249,8 +235,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
return string(resultJSON), nil
|
return string(resultJSON), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreBuildISRCIndex pre-builds the ISRC index for a directory
|
|
||||||
// Call this when app starts or when entering album/playlist screen
|
|
||||||
func PreBuildISRCIndex(outputDir string) error {
|
func PreBuildISRCIndex(outputDir string) error {
|
||||||
if outputDir == "" {
|
if outputDir == "" {
|
||||||
return fmt.Errorf("output directory is required")
|
return fmt.Errorf("output directory is required")
|
||||||
@@ -260,7 +244,6 @@ func PreBuildISRCIndex(outputDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
|
||||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||||
if outputDir == "" || isrc == "" || filePath == "" {
|
if outputDir == "" || isrc == "" || filePath == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ 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:"-"` // Mutex to prevent concurrent VM access
|
VMMu sync.Mutex `json:"-"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
DataDir string `json:"data_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
@@ -55,12 +55,11 @@ type LoadedExtension struct {
|
|||||||
IconPath string `json:"icon_path"`
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionManager manages all loaded extensions
|
|
||||||
type ExtensionManager struct {
|
type ExtensionManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
extensions map[string]*LoadedExtension
|
extensions map[string]*LoadedExtension
|
||||||
extensionsDir string // Base directory for extensions
|
extensionsDir string
|
||||||
dataDir string // Base directory for extension data
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -99,7 +98,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the zip file
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||||
@@ -222,7 +220,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Goja VM
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := m.initializeVM(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
@@ -269,13 +266,11 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Run the extension code
|
|
||||||
_, err = vm.RunString(string(jsCode))
|
_, err = vm.RunString(string(jsCode))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify extension was registered
|
|
||||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||||
return fmt.Errorf("extension did not call registerExtension()")
|
return fmt.Errorf("extension did not call registerExtension()")
|
||||||
}
|
}
|
||||||
@@ -283,7 +278,6 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnloadExtension unloads an extension by ID
|
|
||||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -293,9 +287,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call cleanup if VM is initialized
|
|
||||||
if ext.VM != nil {
|
if ext.VM != nil {
|
||||||
// Try to call cleanup function
|
|
||||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||||
@@ -304,14 +296,12 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from registry
|
|
||||||
delete(m.extensions, extensionID)
|
delete(m.extensions, extensionID)
|
||||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns error if extension not found (gomobile compatible)
|
|
||||||
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()
|
||||||
@@ -323,7 +313,6 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllExtensions returns all loaded extensions
|
|
||||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -347,7 +336,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
ext.Enabled = enabled
|
ext.Enabled = enabled
|
||||||
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])
|
||||||
|
|
||||||
// Persist enabled state to settings store
|
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
|
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
|
||||||
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
|
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
|
||||||
@@ -356,7 +344,6 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadExtensionsFromDirectory scans a directory and loads all valid extensions
|
|
||||||
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
|
||||||
@@ -443,7 +430,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Goja VM
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := m.initializeVM(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
@@ -456,19 +442,16 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveExtension completely removes an extension (unload + delete files)
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unload first
|
|
||||||
if err := m.UnloadExtension(extensionID); err != nil {
|
if err := m.UnloadExtension(extensionID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove source directory
|
|
||||||
if ext.SourceDir != "" {
|
if ext.SourceDir != "" {
|
||||||
if err := os.RemoveAll(ext.SourceDir); err != nil {
|
if err := os.RemoveAll(ext.SourceDir); err != nil {
|
||||||
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
||||||
@@ -490,7 +473,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the zip file
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||||
@@ -554,11 +536,9 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
extDir := existing.SourceDir
|
extDir := existing.SourceDir
|
||||||
wasEnabled := existing.Enabled
|
wasEnabled := existing.Enabled
|
||||||
|
|
||||||
// Cleanup and unload existing extension
|
|
||||||
m.CleanupExtension(existing.ID)
|
m.CleanupExtension(existing.ID)
|
||||||
m.UnloadExtension(existing.ID)
|
m.UnloadExtension(existing.ID)
|
||||||
|
|
||||||
// Remove old source files but keep data directory
|
|
||||||
if extDir != "" {
|
if extDir != "" {
|
||||||
if err := os.RemoveAll(extDir); err != nil {
|
if err := os.RemoveAll(extDir); err != nil {
|
||||||
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
||||||
@@ -637,16 +617,14 @@ type ExtensionUpgradeInfo struct {
|
|||||||
IsInstalled bool `json:"is_installed"`
|
IsInstalled bool `json:"is_installed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkExtensionUpgradeInternal checks if a package file is an upgrade for an existing extension
|
|
||||||
// Internal function that returns struct
|
|
||||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
// Validate file extension
|
// 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")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the zip file
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Cannot open extension file")
|
return nil, fmt.Errorf("Cannot open extension file")
|
||||||
}
|
}
|
||||||
@@ -714,32 +692,32 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter
|
|
||||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||||
extensions := m.GetAllExtensions()
|
extensions := m.GetAllExtensions()
|
||||||
|
|
||||||
type ExtensionInfo struct {
|
type ExtensionInfo 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"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
IconPath string `json:"icon_path,omitempty"`
|
IconPath string `json:"icon_path,omitempty"`
|
||||||
Types []ExtensionType `json:"types"`
|
Types []ExtensionType `json:"types"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Error string `json:"error_message,omitempty"`
|
Error string `json:"error_message,omitempty"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||||
HasDownloadProvider bool `json:"has_download_provider"`
|
HasDownloadProvider bool `json:"has_download_provider"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||||
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"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
infos := make([]ExtensionInfo, len(extensions))
|
infos := make([]ExtensionInfo, len(extensions))
|
||||||
@@ -796,6 +774,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||||
TrackMatching: ext.Manifest.TrackMatching,
|
TrackMatching: ext.Manifest.TrackMatching,
|
||||||
PostProcessing: ext.Manifest.PostProcessing,
|
PostProcessing: ext.Manifest.PostProcessing,
|
||||||
|
Capabilities: ext.Manifest.Capabilities,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,8 +786,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Extension Lifecycle ====================
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -921,7 +898,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnloadAllExtensions unloads all extensions gracefully
|
|
||||||
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))
|
||||||
@@ -938,7 +914,6 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
|||||||
GoLog("[Extension] All extensions unloaded\n")
|
GoLog("[Extension] All extensions unloaded\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The function is called as extension.<actionName>() and can return a result
|
|
||||||
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()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionType represents the type of extension
|
|
||||||
type ExtensionType string
|
type ExtensionType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -15,7 +14,6 @@ const (
|
|||||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SettingType represents the type of a setting field
|
|
||||||
type SettingType string
|
type SettingType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,14 +24,12 @@ const (
|
|||||||
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionPermissions defines what resources an extension can access
|
|
||||||
type ExtensionPermissions struct {
|
type ExtensionPermissions struct {
|
||||||
Network []string `json:"network"` // List of allowed domains
|
Network []string `json:"network"`
|
||||||
Storage bool `json:"storage"` // Whether extension can use storage API
|
Storage bool `json:"storage"`
|
||||||
File bool `json:"file"` // Whether extension can use file API
|
File bool `json:"file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionSetting defines a configurable setting for an extension
|
|
||||||
type ExtensionSetting struct {
|
type ExtensionSetting struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Type SettingType `json:"type"`
|
Type SettingType `json:"type"`
|
||||||
@@ -42,19 +38,17 @@ type ExtensionSetting struct {
|
|||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
Secret bool `json:"secret,omitempty"`
|
Secret bool `json:"secret,omitempty"`
|
||||||
Default interface{} `json:"default,omitempty"`
|
Default interface{} `json:"default,omitempty"`
|
||||||
Options []string `json:"options,omitempty"` // For select type
|
Options []string `json:"options,omitempty"`
|
||||||
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
|
Action string `json:"action,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QualityOption represents a quality option for download providers
|
|
||||||
type QualityOption struct {
|
type QualityOption struct {
|
||||||
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128")
|
ID string `json:"id"`
|
||||||
Label string `json:"label"` // Display name (e.g., "MP3 320kbps")
|
Label string `json:"label"`
|
||||||
Description string `json:"description"` // Optional description (e.g., "Best quality MP3")
|
Description string `json:"description"`
|
||||||
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings
|
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QualitySpecificSetting represents a setting that's specific to a quality option
|
|
||||||
type QualitySpecificSetting struct {
|
type QualitySpecificSetting struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Type SettingType `json:"type"`
|
Type SettingType `json:"type"`
|
||||||
@@ -63,71 +57,72 @@ type QualitySpecificSetting struct {
|
|||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
Secret bool `json:"secret,omitempty"`
|
Secret bool `json:"secret,omitempty"`
|
||||||
Default interface{} `json:"default,omitempty"`
|
Default interface{} `json:"default,omitempty"`
|
||||||
Options []string `json:"options,omitempty"` // For select type
|
Options []string `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchFilter struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchBehaviorConfig defines custom search behavior for an extension
|
|
||||||
type SearchBehaviorConfig struct {
|
type SearchBehaviorConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Whether extension provides custom search
|
Enabled bool `json:"enabled"`
|
||||||
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
|
Primary bool `json:"primary,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"` // Icon for search tab
|
Icon string `json:"icon,omitempty"`
|
||||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
|
||||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
|
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
|
||||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
|
||||||
|
Filters []SearchFilter `json:"filters,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// URLHandlerConfig defines custom URL handling for an extension
|
|
||||||
type URLHandlerConfig struct {
|
type URLHandlerConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Whether extension handles URLs
|
Enabled bool `json:"enabled"`
|
||||||
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
|
Patterns []string `json:"patterns,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackMatchingConfig defines custom track matching behavior
|
|
||||||
type TrackMatchingConfig struct {
|
type TrackMatchingConfig struct {
|
||||||
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
|
CustomMatching bool `json:"customMatching"`
|
||||||
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom"
|
Strategy string `json:"strategy,omitempty"`
|
||||||
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching
|
DurationTolerance int `json:"durationTolerance,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostProcessingHook defines a post-processing hook
|
|
||||||
type PostProcessingHook struct {
|
type PostProcessingHook struct {
|
||||||
ID string `json:"id"` // Unique identifier
|
ID string `json:"id"`
|
||||||
Name string `json:"name"` // Display name
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"` // Description
|
Description string `json:"description,omitempty"`
|
||||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default
|
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
|
||||||
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"])
|
SupportedFormats []string `json:"supportedFormats,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostProcessingConfig defines post-processing capabilities
|
|
||||||
type PostProcessingConfig struct {
|
type PostProcessingConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Whether extension provides post-processing
|
Enabled bool `json:"enabled"`
|
||||||
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
|
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionManifest represents the manifest.json of an extension
|
|
||||||
type ExtensionManifest struct {
|
type ExtensionManifest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
Icon string `json:"icon,omitempty"`
|
||||||
Types []ExtensionType `json:"type"`
|
Types []ExtensionType `json:"type"`
|
||||||
Permissions ExtensionPermissions `json:"permissions"`
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManifestValidationError represents a validation error in the manifest
|
|
||||||
type ManifestValidationError struct {
|
type ManifestValidationError struct {
|
||||||
Field string
|
Field string
|
||||||
Message string
|
Message string
|
||||||
@@ -137,7 +132,6 @@ func (e *ManifestValidationError) Error() string {
|
|||||||
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseManifest parses and validates a manifest from JSON bytes
|
|
||||||
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||||
var manifest ExtensionManifest
|
var manifest ExtensionManifest
|
||||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
@@ -181,7 +175,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate settings if present
|
|
||||||
for i, setting := range m.Settings {
|
for i, setting := range m.Settings {
|
||||||
if strings.TrimSpace(setting.Key) == "" {
|
if strings.TrimSpace(setting.Key) == "" {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
@@ -216,7 +209,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasType checks if the extension has a specific type
|
|
||||||
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||||
for _, et := range m.Types {
|
for _, et := range m.Types {
|
||||||
if et == t {
|
if et == t {
|
||||||
@@ -226,17 +218,14 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsMetadataProvider returns true if extension provides metadata
|
|
||||||
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||||
return m.HasType(ExtensionTypeMetadataProvider)
|
return m.HasType(ExtensionTypeMetadataProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDownloadProvider returns true if extension provides downloads
|
|
||||||
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||||
return m.HasType(ExtensionTypeDownloadProvider)
|
return m.HasType(ExtensionTypeDownloadProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDomainAllowed checks if a domain is in the allowed network permissions
|
|
||||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
for _, allowed := range m.Permissions.Network {
|
for _, allowed := range m.Permissions.Network {
|
||||||
@@ -246,7 +235,7 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
|||||||
}
|
}
|
||||||
// Support wildcard subdomains (e.g., *.example.com)
|
// Support wildcard subdomains (e.g., *.example.com)
|
||||||
if strings.HasPrefix(allowed, "*.") {
|
if strings.HasPrefix(allowed, "*.") {
|
||||||
suffix := allowed[1:] // Remove the *
|
suffix := allowed[1:]
|
||||||
if strings.HasSuffix(domain, suffix) {
|
if strings.HasSuffix(domain, suffix) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -255,27 +244,22 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasCustomSearch returns true if extension provides custom search
|
|
||||||
func (m *ExtensionManifest) HasCustomSearch() bool {
|
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||||
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasCustomMatching returns true if extension provides custom track matching
|
|
||||||
func (m *ExtensionManifest) HasCustomMatching() bool {
|
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||||
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPostProcessing returns true if extension provides post-processing
|
|
||||||
func (m *ExtensionManifest) HasPostProcessing() bool {
|
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasURLHandler returns true if extension handles custom URLs
|
|
||||||
func (m *ExtensionManifest) HasURLHandler() bool {
|
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchesURL checks if a URL matches any of the extension's URL patterns
|
|
||||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||||
if !m.HasURLHandler() {
|
if !m.HasURLHandler() {
|
||||||
return false
|
return false
|
||||||
@@ -284,7 +268,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
|||||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||||
for _, pattern := range m.URLHandler.Patterns {
|
for _, pattern := range m.URLHandler.Patterns {
|
||||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||||
// Check if URL contains the pattern (host match)
|
|
||||||
if strings.Contains(urlStr, pattern) {
|
if strings.Contains(urlStr, pattern) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -292,7 +275,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPostProcessingHooks returns all post-processing hooks
|
|
||||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||||
if m.PostProcessing == nil {
|
if m.PostProcessing == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -300,7 +282,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
|||||||
return m.PostProcessing.Hooks
|
return m.PostProcessing.Hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToJSON serializes the manifest to JSON
|
|
||||||
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||||
return json.Marshal(m)
|
return json.Marshal(m)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,27 +25,26 @@ type ExtTrackMetadata struct {
|
|||||||
AlbumArtist string `json:"album_artist,omitempty"`
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
DurationMS int `json:"duration_ms"`
|
DurationMS int `json:"duration_ms"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
Images string `json:"images,omitempty"` // Alternative field for cover URL (used by some extensions)
|
Images string `json:"images,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
ProviderID string `json:"provider_id"`
|
ProviderID string `json:"provider_id"`
|
||||||
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
|
ItemType string `json:"item_type,omitempty"`
|
||||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
// Enrichment fields from Odesli/song.link
|
|
||||||
TidalID string `json:"tidal_id,omitempty"`
|
TidalID string `json:"tidal_id,omitempty"`
|
||||||
QobuzID string `json:"qobuz_id,omitempty"`
|
QobuzID string `json:"qobuz_id,omitempty"`
|
||||||
DeezerID string `json:"deezer_id,omitempty"`
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
SpotifyID string `json:"spotify_id,omitempty"`
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
|
ExternalLinks map[string]string `json:"external_links,omitempty"`
|
||||||
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
|
|
||||||
Label string `json:"label,omitempty"` // Record label
|
Label string `json:"label,omitempty"`
|
||||||
Copyright string `json:"copyright,omitempty"` // Copyright information
|
Copyright string `json:"copyright,omitempty"`
|
||||||
Genre string `json:"genre,omitempty"` // Music genre(s)
|
Genre string `json:"genre,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
|
|
||||||
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||||
if t.CoverURL != "" {
|
if t.CoverURL != "" {
|
||||||
return t.CoverURL
|
return t.CoverURL
|
||||||
@@ -53,11 +52,11 @@ func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
|||||||
return t.Images
|
return t.Images
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtAlbumMetadata represents album metadata from an extension
|
|
||||||
type ExtAlbumMetadata struct {
|
type ExtAlbumMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
@@ -66,34 +65,28 @@ type ExtAlbumMetadata struct {
|
|||||||
ProviderID string `json:"provider_id"`
|
ProviderID string `json:"provider_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtArtistMetadata represents artist metadata from an extension
|
|
||||||
type ExtArtistMetadata struct {
|
type ExtArtistMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
Listeners int `json:"listeners,omitempty"` // Monthly listeners
|
Listeners int `json:"listeners,omitempty"`
|
||||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks
|
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||||
ProviderID string `json:"provider_id"`
|
ProviderID string `json:"provider_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtSearchResult represents search results from an extension
|
|
||||||
type ExtSearchResult struct {
|
type ExtSearchResult struct {
|
||||||
Tracks []ExtTrackMetadata `json:"tracks"`
|
Tracks []ExtTrackMetadata `json:"tracks"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Download Types ====================
|
|
||||||
|
|
||||||
// ExtAvailabilityResult represents availability check result
|
|
||||||
type ExtAvailabilityResult struct {
|
type ExtAvailabilityResult struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
TrackID string `json:"track_id,omitempty"`
|
TrackID string `json:"track_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtDownloadURLResult represents download URL info
|
|
||||||
type ExtDownloadURLResult struct {
|
type ExtDownloadURLResult struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
@@ -101,7 +94,6 @@ type ExtDownloadURLResult struct {
|
|||||||
SampleRate int `json:"sample_rate,omitempty"`
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtDownloadResult represents download result from an extension
|
|
||||||
type ExtDownloadResult struct {
|
type ExtDownloadResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
@@ -109,7 +101,7 @@ type ExtDownloadResult struct {
|
|||||||
SampleRate int `json:"sample_rate,omitempty"`
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
// Metadata returned by extension (optional - if provided, can skip enrichment)
|
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Artist string `json:"artist,omitempty"`
|
Artist string `json:"artist,omitempty"`
|
||||||
Album string `json:"album,omitempty"`
|
Album string `json:"album,omitempty"`
|
||||||
@@ -121,15 +113,11 @@ type ExtDownloadResult struct {
|
|||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Provider Wrapper ====================
|
|
||||||
|
|
||||||
// ExtensionProviderWrapper wraps an extension to call its provider methods
|
|
||||||
type ExtensionProviderWrapper struct {
|
type ExtensionProviderWrapper struct {
|
||||||
extension *LoadedExtension
|
extension *LoadedExtension
|
||||||
vm *goja.Runtime
|
vm *goja.Runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExtensionProviderWrapper creates a new provider wrapper
|
|
||||||
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
|
func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper {
|
||||||
return &ExtensionProviderWrapper{
|
return &ExtensionProviderWrapper{
|
||||||
extension: ext,
|
extension: ext,
|
||||||
@@ -137,9 +125,6 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Metadata Provider Methods ====================
|
|
||||||
|
|
||||||
// SearchTracks searches for tracks using the extension
|
|
||||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
@@ -149,11 +134,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Call extension's searchTracks function
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') {
|
||||||
@@ -175,7 +158,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
return nil, fmt.Errorf("searchTracks returned null")
|
return nil, fmt.Errorf("searchTracks returned null")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert result to Go struct
|
|
||||||
exported := result.Export()
|
exported := result.Export()
|
||||||
jsonBytes, err := json.Marshal(exported)
|
jsonBytes, err := json.Marshal(exported)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,14 +166,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
|
|
||||||
var searchResult ExtSearchResult
|
var searchResult ExtSearchResult
|
||||||
|
|
||||||
// Try to parse as ExtSearchResult object first
|
|
||||||
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
|
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
|
||||||
// If that fails, try parsing as array of tracks directly
|
|
||||||
var tracks []ExtTrackMetadata
|
var tracks []ExtTrackMetadata
|
||||||
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
|
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
|
||||||
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
|
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
|
||||||
}
|
}
|
||||||
// Wrap array in ExtSearchResult
|
|
||||||
searchResult = ExtSearchResult{
|
searchResult = ExtSearchResult{
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
Total: len(tracks),
|
Total: len(tracks),
|
||||||
@@ -205,7 +184,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
return &searchResult, nil
|
return &searchResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrack gets track details by ID
|
|
||||||
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
@@ -215,7 +193,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -255,7 +232,6 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
|||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlbum gets album details by ID
|
|
||||||
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
@@ -265,7 +241,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -308,7 +283,6 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
|||||||
return &album, nil
|
return &album, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArtist gets artist details by ID
|
|
||||||
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
@@ -318,7 +292,6 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -358,27 +331,22 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
return &artist, nil
|
return &artist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC)
|
|
||||||
// This is called lazily when download starts, not when playlist/album is loaded
|
|
||||||
// Extension should implement enrichTrack(track) function that returns enriched track
|
|
||||||
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return track, nil // Not a metadata provider, return as-is
|
return track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return track, nil // Extension disabled, return as-is
|
return track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Convert track to JSON for passing to JS
|
|
||||||
trackJSON, err := json.Marshal(track)
|
trackJSON, err := json.Marshal(track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
|
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
|
||||||
return track, nil // Return original on error
|
return track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -398,10 +366,9 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
} else {
|
} else {
|
||||||
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
|
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
|
||||||
}
|
}
|
||||||
return track, nil // Return original on error
|
return track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If extension doesn't implement enrichTrack or returns null, return original
|
|
||||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
@@ -419,18 +386,11 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve provider ID
|
|
||||||
enrichedTrack.ProviderID = track.ProviderID
|
enrichedTrack.ProviderID = track.ProviderID
|
||||||
|
|
||||||
GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n",
|
|
||||||
p.extension.ID, track.ISRC, enrichedTrack.ISRC)
|
|
||||||
|
|
||||||
return &enrichedTrack, nil
|
return &enrichedTrack, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Download Provider Methods ====================
|
|
||||||
|
|
||||||
// CheckAvailability checks if a track is available for download
|
|
||||||
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
|
func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
@@ -440,7 +400,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -479,7 +438,6 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
|||||||
return &availability, nil
|
return &availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadURL gets the download URL for a track
|
|
||||||
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
@@ -489,7 +447,6 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -528,10 +485,8 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
return &urlResult, nil
|
return &urlResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtDownloadTimeout is longer for extension download operations (5 minutes)
|
|
||||||
const ExtDownloadTimeout = 5 * time.Minute
|
const ExtDownloadTimeout = 5 * time.Minute
|
||||||
|
|
||||||
// Download downloads a track with progress reporting
|
|
||||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
@@ -541,15 +496,12 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Set up progress callback in VM
|
|
||||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) > 0 {
|
if len(call.Arguments) > 0 {
|
||||||
percent := int(call.Arguments[0].ToInteger())
|
percent := int(call.Arguments[0].ToInteger())
|
||||||
// Clamp to 0-100
|
|
||||||
if percent < 0 {
|
if percent < 0 {
|
||||||
percent = 0
|
percent = 0
|
||||||
}
|
}
|
||||||
@@ -572,7 +524,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
|||||||
})()
|
})()
|
||||||
`, trackID, quality, outputPath)
|
`, trackID, quality, outputPath)
|
||||||
|
|
||||||
// Use longer timeout for downloads (5 minutes)
|
|
||||||
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
|
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := err.Error()
|
errMsg := err.Error()
|
||||||
@@ -618,9 +569,6 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
|||||||
return &downloadResult, nil
|
return &downloadResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Extension Manager Provider Methods ====================
|
|
||||||
|
|
||||||
// GetMetadataProviders returns all enabled metadata provider extensions
|
|
||||||
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -634,7 +582,6 @@ func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper {
|
|||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadProviders returns all enabled download provider extensions
|
|
||||||
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -648,7 +595,6 @@ func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper {
|
|||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTracksWithExtensions searches all metadata providers
|
|
||||||
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
|
func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||||
providers := m.GetMetadataProviders()
|
providers := m.GetMetadataProviders()
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
@@ -670,18 +616,12 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
|||||||
return allTracks, nil
|
return allTracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Provider Priority ====================
|
|
||||||
|
|
||||||
// providerPriority stores the order of download providers
|
|
||||||
var providerPriority []string
|
var providerPriority []string
|
||||||
var providerPriorityMu sync.RWMutex
|
var providerPriorityMu sync.RWMutex
|
||||||
|
|
||||||
// metadataProviderPriority stores the order of metadata providers
|
|
||||||
var metadataProviderPriority []string
|
var metadataProviderPriority []string
|
||||||
var metadataProviderPriorityMu sync.RWMutex
|
var metadataProviderPriorityMu sync.RWMutex
|
||||||
|
|
||||||
// SetProviderPriority sets the order of download providers
|
|
||||||
// providerIDs should include both built-in ("tidal", "qobuz", "amazon") and extension IDs
|
|
||||||
func SetProviderPriority(providerIDs []string) {
|
func SetProviderPriority(providerIDs []string) {
|
||||||
providerPriorityMu.Lock()
|
providerPriorityMu.Lock()
|
||||||
defer providerPriorityMu.Unlock()
|
defer providerPriorityMu.Unlock()
|
||||||
@@ -689,13 +629,11 @@ func SetProviderPriority(providerIDs []string) {
|
|||||||
GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
|
GoLog("[Extension] Download provider priority set: %v\n", providerIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProviderPriority returns the current provider priority order
|
|
||||||
func GetProviderPriority() []string {
|
func GetProviderPriority() []string {
|
||||||
providerPriorityMu.RLock()
|
providerPriorityMu.RLock()
|
||||||
defer providerPriorityMu.RUnlock()
|
defer providerPriorityMu.RUnlock()
|
||||||
|
|
||||||
if len(providerPriority) == 0 {
|
if len(providerPriority) == 0 {
|
||||||
// Default order: built-in providers first
|
|
||||||
return []string{"tidal", "qobuz", "amazon"}
|
return []string{"tidal", "qobuz", "amazon"}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,8 +642,6 @@ func GetProviderPriority() []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMetadataProviderPriority sets the order of metadata providers
|
|
||||||
// providerIDs should include both built-in ("spotify", "deezer") and extension IDs
|
|
||||||
func SetMetadataProviderPriority(providerIDs []string) {
|
func SetMetadataProviderPriority(providerIDs []string) {
|
||||||
metadataProviderPriorityMu.Lock()
|
metadataProviderPriorityMu.Lock()
|
||||||
defer metadataProviderPriorityMu.Unlock()
|
defer metadataProviderPriorityMu.Unlock()
|
||||||
@@ -713,13 +649,11 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
|||||||
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
|
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadataProviderPriority returns the current metadata provider priority order
|
|
||||||
func GetMetadataProviderPriority() []string {
|
func GetMetadataProviderPriority() []string {
|
||||||
metadataProviderPriorityMu.RLock()
|
metadataProviderPriorityMu.RLock()
|
||||||
defer metadataProviderPriorityMu.RUnlock()
|
defer metadataProviderPriorityMu.RUnlock()
|
||||||
|
|
||||||
if len(metadataProviderPriority) == 0 {
|
if len(metadataProviderPriority) == 0 {
|
||||||
// Default order: built-in providers first
|
|
||||||
return []string{"deezer", "spotify"}
|
return []string{"deezer", "spotify"}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,30 +662,34 @@ func GetMetadataProviderPriority() []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// isBuiltInProvider checks if a provider ID is a built-in provider
|
|
||||||
func isBuiltInProvider(providerID string) bool {
|
func isBuiltInProvider(providerID string) bool {
|
||||||
switch providerID {
|
switch providerID {
|
||||||
case "tidal", "qobuz", "amazon":
|
case "tidal", "qobuz", "amazon", "deezer":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Download with Fallback ====================
|
|
||||||
|
|
||||||
// DownloadWithExtensionFallback tries to download from providers in priority order
|
|
||||||
// Includes both built-in providers and extension providers
|
|
||||||
// If req.Source is set (extension ID), that extension is tried first
|
|
||||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||||
priority := GetProviderPriority()
|
priority := GetProviderPriority()
|
||||||
extManager := GetExtensionManager()
|
extManager := GetExtensionManager()
|
||||||
|
|
||||||
var lastErr error
|
if req.Service != "" && isBuiltInProvider(req.Service) {
|
||||||
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
|
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||||
|
newPriority := []string{req.Service}
|
||||||
|
for _, p := range priority {
|
||||||
|
if p != req.Service {
|
||||||
|
newPriority = append(newPriority, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
priority = newPriority
|
||||||
|
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
var skipBuiltIn bool
|
||||||
|
|
||||||
// LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC)
|
|
||||||
// This is done lazily at download time, not when playlist/album is loaded
|
|
||||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||||
ext, err := extManager.GetExtension(req.Source)
|
ext, err := extManager.GetExtension(req.Source)
|
||||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
||||||
@@ -795,7 +733,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if enrichedTrack.Artists != "" {
|
if enrichedTrack.Artists != "" {
|
||||||
req.ArtistName = enrichedTrack.Artists
|
req.ArtistName = enrichedTrack.Artists
|
||||||
}
|
}
|
||||||
// Copy extended metadata from enrichment (label, copyright, genre, release_date)
|
|
||||||
if enrichedTrack.Label != "" && req.Label == "" {
|
if enrichedTrack.Label != "" && req.Label == "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||||
req.Label = enrichedTrack.Label
|
req.Label = enrichedTrack.Label
|
||||||
@@ -816,7 +753,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If source extension is specified, try it first before the priority list
|
|
||||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||||
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
|
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
|
||||||
|
|
||||||
@@ -826,15 +762,12 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
// For tracks from extension search, use the track ID directly (e.g., "youtube:VIDEO_ID")
|
trackID := req.SpotifyID
|
||||||
// The extension already knows how to handle this ID
|
|
||||||
trackID := req.SpotifyID // This contains the extension's track ID (e.g., "youtube:xxx")
|
|
||||||
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||||
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPath(req)
|
||||||
|
|
||||||
// Download directly using the track ID from the extension
|
|
||||||
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||||
@@ -854,7 +787,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed genre and label if provided (from Deezer metadata)
|
|
||||||
if req.Genre != "" || req.Label != "" {
|
if req.Genre != "" || req.Label != "" {
|
||||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||||
@@ -863,7 +795,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If extension has skipMetadataEnrichment, copy metadata
|
|
||||||
if ext.Manifest.SkipMetadataEnrichment {
|
if ext.Manifest.SkipMetadataEnrichment {
|
||||||
resp.SkipMetadataEnrichment = true
|
resp.SkipMetadataEnrichment = true
|
||||||
if result.Title != "" {
|
if result.Title != "" {
|
||||||
@@ -913,12 +844,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
||||||
|
|
||||||
// If skipBuiltInFallback is true, don't continue to other providers
|
|
||||||
if skipBuiltIn {
|
if skipBuiltIn {
|
||||||
GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n")
|
GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n")
|
||||||
return &DownloadResponse{
|
return &DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: fmt.Sprintf("Download failed: %v", lastErr),
|
Error: "Download failed: " + lastErr.Error(),
|
||||||
ErrorType: "extension_error",
|
ErrorType: "extension_error",
|
||||||
Service: req.Source,
|
Service: req.Source,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -928,14 +858,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue with priority list
|
|
||||||
for _, providerID := range priority {
|
for _, providerID := range priority {
|
||||||
// Skip if we already tried this as source
|
|
||||||
if providerID == req.Source {
|
if providerID == req.Source {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip built-in providers if skipBuiltIn is set
|
|
||||||
if skipBuiltIn && isBuiltInProvider(providerID) {
|
if skipBuiltIn && isBuiltInProvider(providerID) {
|
||||||
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
||||||
continue
|
continue
|
||||||
@@ -944,7 +871,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||||
|
|
||||||
if isBuiltInProvider(providerID) {
|
if isBuiltInProvider(providerID) {
|
||||||
// For built-in providers, enrich with Deezer metadata if not already present
|
|
||||||
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -965,11 +891,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use built-in provider
|
|
||||||
result, err := tryBuiltInProvider(providerID, req)
|
result, err := tryBuiltInProvider(providerID, req)
|
||||||
if err == nil && result.Success {
|
if err == nil && result.Success {
|
||||||
result.Service = providerID
|
result.Service = providerID
|
||||||
// Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion)
|
|
||||||
if req.Label != "" {
|
if req.Label != "" {
|
||||||
result.Label = req.Label
|
result.Label = req.Label
|
||||||
}
|
}
|
||||||
@@ -997,7 +921,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Try extension provider
|
|
||||||
ext, err := extManager.GetExtension(providerID)
|
ext, err := extManager.GetExtension(providerID)
|
||||||
if err != nil || !ext.Enabled || ext.Error != "" {
|
if err != nil || !ext.Enabled || ext.Error != "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
|
||||||
@@ -1040,7 +963,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed genre and label if provided (from Deezer metadata)
|
|
||||||
if req.Genre != "" || req.Label != "" {
|
if req.Genre != "" || req.Label != "" {
|
||||||
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||||
@@ -1049,10 +971,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If extension has skipMetadataEnrichment and returned metadata, use it
|
|
||||||
if ext.Manifest.SkipMetadataEnrichment {
|
if ext.Manifest.SkipMetadataEnrichment {
|
||||||
resp.SkipMetadataEnrichment = true
|
resp.SkipMetadataEnrichment = true
|
||||||
// Copy metadata from extension result if provided
|
|
||||||
if result.Title != "" {
|
if result.Title != "" {
|
||||||
resp.Title = result.Title
|
resp.Title = result.Title
|
||||||
}
|
}
|
||||||
@@ -1105,7 +1025,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
return &DownloadResponse{
|
return &DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: fmt.Sprintf("All providers failed. Last error: %v", lastErr),
|
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||||
ErrorType: "not_found",
|
ErrorType: "not_found",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -1117,7 +1037,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// tryBuiltInProvider attempts download from a built-in provider
|
|
||||||
func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) {
|
func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) {
|
||||||
req.Service = providerID
|
req.Service = providerID
|
||||||
|
|
||||||
@@ -1163,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
Title: amazonResult.Title,
|
Title: amazonResult.Title,
|
||||||
Artist: amazonResult.Artist,
|
Artist: amazonResult.Artist,
|
||||||
Album: amazonResult.Album,
|
Album: amazonResult.Album,
|
||||||
ReleaseDate: amazonResult.ReleaseDate,
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
TrackNumber: amazonResult.TrackNumber,
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
|
LyricsLRC: amazonResult.LyricsLRC,
|
||||||
|
DecryptionKey: amazonResult.DecryptionKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
@@ -1200,11 +1121,16 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
|
LyricsLRC: result.LyricsLRC,
|
||||||
|
DecryptionKey: result.DecryptionKey,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildOutputPath builds the output file path from request
|
|
||||||
func buildOutputPath(req DownloadRequest) string {
|
func buildOutputPath(req DownloadRequest) string {
|
||||||
|
if strings.TrimSpace(req.OutputPath) != "" {
|
||||||
|
return strings.TrimSpace(req.OutputPath)
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -1220,12 +1146,16 @@ func buildOutputPath(req DownloadRequest) string {
|
|||||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename)
|
ext := strings.TrimSpace(req.OutputExt)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".flac"
|
||||||
|
} else if !strings.HasPrefix(ext, ".") {
|
||||||
|
ext = "." + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Custom Search ====================
|
|
||||||
|
|
||||||
// CustomSearch performs a custom search using an extension's search function
|
|
||||||
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||||
if !p.extension.Manifest.HasCustomSearch() {
|
if !p.extension.Manifest.HasCustomSearch() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
|
||||||
@@ -1235,21 +1165,33 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Convert options to JSON
|
if options == nil {
|
||||||
optionsJSON, _ := json.Marshal(options)
|
options = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
// Avoid embedding user input directly into JS source. Some inputs can trigger
|
||||||
|
// parser/runtime edge cases on specific devices/Goja builds.
|
||||||
|
const queryVar = "__sf_custom_search_query"
|
||||||
|
const optionsVar = "__sf_custom_search_options"
|
||||||
|
global := p.vm.GlobalObject()
|
||||||
|
_ = global.Set(queryVar, query)
|
||||||
|
_ = global.Set(optionsVar, options)
|
||||||
|
defer func() {
|
||||||
|
global.Delete(queryVar)
|
||||||
|
global.Delete(optionsVar)
|
||||||
|
}()
|
||||||
|
|
||||||
|
const script = `
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
|
||||||
return extension.customSearch(%q, %s);
|
return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
`, query, string(optionsJSON))
|
`
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1260,7 +1202,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||||
// Return empty array instead of error for no results
|
|
||||||
return []ExtTrackMetadata{}, nil
|
return []ExtTrackMetadata{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,7 +1216,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
return nil, fmt.Errorf("failed to parse search result: %w", err)
|
return nil, fmt.Errorf("failed to parse search result: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return empty array if no tracks found
|
|
||||||
if tracks == nil {
|
if tracks == nil {
|
||||||
tracks = []ExtTrackMetadata{}
|
tracks = []ExtTrackMetadata{}
|
||||||
}
|
}
|
||||||
@@ -1287,20 +1227,16 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Custom URL Handler ====================
|
|
||||||
|
|
||||||
// ExtURLHandleResult represents the result of URL handling
|
|
||||||
type ExtURLHandleResult struct {
|
type ExtURLHandleResult struct {
|
||||||
Type string `json:"type"` // "track", "album", "playlist", "artist"
|
Type string `json:"type"`
|
||||||
Track *ExtTrackMetadata `json:"track,omitempty"` // For single track
|
Track *ExtTrackMetadata `json:"track,omitempty"`
|
||||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"` // For album/playlist
|
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
||||||
Album *ExtAlbumMetadata `json:"album,omitempty"` // Album info
|
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
||||||
Artist *ExtArtistMetadata `json:"artist,omitempty"` // Artist info
|
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
||||||
Name string `json:"name,omitempty"` // Playlist/album name
|
Name string `json:"name,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"` // Cover image
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleURL processes a URL using the extension's URL handler
|
|
||||||
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||||
if !p.extension.Manifest.HasURLHandler() {
|
if !p.extension.Manifest.HasURLHandler() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
|
||||||
@@ -1310,7 +1246,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -1346,7 +1281,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
|
return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set provider ID on tracks
|
|
||||||
if handleResult.Track != nil {
|
if handleResult.Track != nil {
|
||||||
handleResult.Track.ProviderID = p.extension.ID
|
handleResult.Track.ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -1375,9 +1309,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
return &handleResult, nil
|
return &handleResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Custom Track Matching ====================
|
|
||||||
|
|
||||||
// MatchTrackResult represents the result of custom track matching
|
|
||||||
type MatchTrackResult struct {
|
type MatchTrackResult struct {
|
||||||
Matched bool `json:"matched"`
|
Matched bool `json:"matched"`
|
||||||
TrackID string `json:"track_id,omitempty"`
|
TrackID string `json:"track_id,omitempty"`
|
||||||
@@ -1385,7 +1316,6 @@ type MatchTrackResult struct {
|
|||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchTrack uses extension's custom matching algorithm
|
|
||||||
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
|
func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) {
|
||||||
if !p.extension.Manifest.HasCustomMatching() {
|
if !p.extension.Manifest.HasCustomMatching() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID)
|
||||||
@@ -1395,7 +1325,6 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -1437,22 +1366,26 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
|||||||
return &matchResult, nil
|
return &matchResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Post-Processing ====================
|
|
||||||
|
|
||||||
// PostProcessResult represents the result of post-processing
|
|
||||||
type PostProcessResult struct {
|
type PostProcessResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
NewFilePath string `json:"new_file_path,omitempty"`
|
NewFilePath string `json:"new_file_path,omitempty"`
|
||||||
|
NewFileURI string `json:"new_file_uri,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
// Additional metadata that may have changed
|
BitDepth int `json:"bit_depth,omitempty"`
|
||||||
BitDepth int `json:"bit_depth,omitempty"`
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
SampleRate int `json:"sample_rate,omitempty"`
|
}
|
||||||
|
|
||||||
|
type PostProcessInput struct {
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
URI string `json:"uri,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
IsSAF bool `json:"is_saf,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostProcessTimeout is longer for post-processing (2 minutes)
|
|
||||||
const PostProcessTimeout = 2 * time.Minute
|
const PostProcessTimeout = 2 * time.Minute
|
||||||
|
|
||||||
// PostProcess runs post-processing hooks on a downloaded file
|
|
||||||
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||||
if !p.extension.Manifest.HasPostProcessing() {
|
if !p.extension.Manifest.HasPostProcessing() {
|
||||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||||
@@ -1462,7 +1395,6 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
|||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock VM to prevent concurrent access
|
|
||||||
p.extension.VMMu.Lock()
|
p.extension.VMMu.Lock()
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -1516,9 +1448,75 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
|||||||
return &postResult, nil
|
return &postResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Extension Manager Advanced Methods ====================
|
func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
|
||||||
|
if !p.extension.Manifest.HasPostProcessing() {
|
||||||
|
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.extension.Enabled {
|
||||||
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.extension.VMMu.Lock()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
|
inputJSON, _ := json.Marshal(input)
|
||||||
|
filePath := input.Path
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined') {
|
||||||
|
if (typeof extension.postProcessV2 === 'function') {
|
||||||
|
return extension.postProcessV2(%s, %s, %q);
|
||||||
|
}
|
||||||
|
if (typeof extension.postProcess === 'function') {
|
||||||
|
return extension.postProcess(%q, %s, %q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()
|
||||||
|
`, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID)
|
||||||
|
|
||||||
|
result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
if IsTimeoutError(err) {
|
||||||
|
errMsg = "postProcess timeout: extension took too long to complete"
|
||||||
|
}
|
||||||
|
return &PostProcessResult{
|
||||||
|
Success: false,
|
||||||
|
Error: errMsg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||||
|
return &PostProcessResult{
|
||||||
|
Success: false,
|
||||||
|
Error: "postProcess returned null",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := result.Export()
|
||||||
|
jsonBytes, err := json.Marshal(exported)
|
||||||
|
if err != nil {
|
||||||
|
return &PostProcessResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to marshal result: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var postResult PostProcessResult
|
||||||
|
if err := json.Unmarshal(jsonBytes, &postResult); err != nil {
|
||||||
|
return &PostProcessResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to parse result: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &postResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSearchProviders returns all extensions that provide custom search
|
|
||||||
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -1532,7 +1530,6 @@ func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
|||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetURLHandlers returns all extensions that handle custom URLs
|
|
||||||
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -1546,7 +1543,6 @@ func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
|||||||
return providers
|
return providers
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindURLHandler finds an extension that can handle the given URL
|
|
||||||
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
|
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -1559,14 +1555,11 @@ func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtURLHandleResultWithExtID wraps ExtURLHandleResult with extension ID for gomobile compatibility
|
|
||||||
type ExtURLHandleResultWithExtID struct {
|
type ExtURLHandleResultWithExtID struct {
|
||||||
Result *ExtURLHandleResult
|
Result *ExtURLHandleResult
|
||||||
ExtensionID string
|
ExtensionID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleURLWithExtension tries to handle a URL with any matching extension
|
|
||||||
// Returns result with extension ID, or error if no handler found
|
|
||||||
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
||||||
handler := m.FindURLHandler(url)
|
handler := m.FindURLHandler(url)
|
||||||
if handler == nil {
|
if handler == nil {
|
||||||
@@ -1646,3 +1639,58 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
|||||||
|
|
||||||
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
|
||||||
|
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
|
||||||
|
providers := m.GetPostProcessingProviders()
|
||||||
|
if len(providers) == 0 {
|
||||||
|
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentInput := input
|
||||||
|
for _, provider := range providers {
|
||||||
|
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
||||||
|
for _, hook := range hooks {
|
||||||
|
if !hook.DefaultEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(currentInput.Path))
|
||||||
|
if ext == "" && currentInput.Name != "" {
|
||||||
|
ext = strings.ToLower(filepath.Ext(currentInput.Name))
|
||||||
|
}
|
||||||
|
if len(hook.SupportedFormats) > 0 && ext != "" {
|
||||||
|
supported := false
|
||||||
|
for _, format := range hook.SupportedFormats {
|
||||||
|
if "."+format == ext || format == ext[1:] {
|
||||||
|
supported = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !supported {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[PostProcessV2] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentInput.Path)
|
||||||
|
|
||||||
|
result, err := provider.PostProcessV2(currentInput, metadata, hook.ID)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[PostProcessV2] Hook %s failed: %v\n", hook.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Success && result.NewFilePath != "" {
|
||||||
|
currentInput.Path = result.NewFilePath
|
||||||
|
if currentInput.Name == "" {
|
||||||
|
currentInput.Name = filepath.Base(result.NewFilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.Success && result.NewFileURI != "" {
|
||||||
|
currentInput.URI = result.NewFileURI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,9 +26,8 @@ type ExtensionAuthState struct {
|
|||||||
RefreshToken string
|
RefreshToken string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
IsAuthenticated bool
|
IsAuthenticated bool
|
||||||
// PKCE support
|
PKCEVerifier string
|
||||||
PKCEVerifier string
|
PKCEChallenge string
|
||||||
PKCEChallenge string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingAuthRequest struct {
|
type PendingAuthRequest struct {
|
||||||
@@ -39,7 +41,6 @@ var (
|
|||||||
pendingAuthRequestsMu sync.RWMutex
|
pendingAuthRequestsMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter)
|
|
||||||
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||||
pendingAuthRequestsMu.RLock()
|
pendingAuthRequestsMu.RLock()
|
||||||
defer pendingAuthRequestsMu.RUnlock()
|
defer pendingAuthRequestsMu.RUnlock()
|
||||||
@@ -105,8 +106,16 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Jar: jar,
|
Jar: jar,
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
// Validate redirect target domain against allowed domains
|
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()
|
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) {
|
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||||
return &RedirectBlockedError{Domain: domain}
|
return &RedirectBlockedError{Domain: domain}
|
||||||
@@ -115,7 +124,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||||
}
|
}
|
||||||
// Default redirect limit (10)
|
|
||||||
if len(via) >= 10 {
|
if len(via) >= 10 {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
}
|
}
|
||||||
@@ -141,35 +149,48 @@ func (e *RedirectBlockedError) Error() string {
|
|||||||
|
|
||||||
// isPrivateIP checks if a hostname resolves to a private/local IP address
|
// isPrivateIP checks if a hostname resolves to a private/local IP address
|
||||||
func isPrivateIP(host string) bool {
|
func isPrivateIP(host string) bool {
|
||||||
// Block common private network patterns
|
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||||
// This is a simple check - for production, consider DNS resolution
|
if hostLower == "" {
|
||||||
privatePatterns := []string{
|
return false
|
||||||
"localhost",
|
|
||||||
"127.",
|
|
||||||
"10.",
|
|
||||||
"172.16.", "172.17.", "172.18.", "172.19.",
|
|
||||||
"172.20.", "172.21.", "172.22.", "172.23.",
|
|
||||||
"172.24.", "172.25.", "172.26.", "172.27.",
|
|
||||||
"172.28.", "172.29.", "172.30.", "172.31.",
|
|
||||||
"192.168.",
|
|
||||||
"169.254.",
|
|
||||||
"::1",
|
|
||||||
"fc00:",
|
|
||||||
"fe80:",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hostLower := host
|
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
|
||||||
for _, pattern := range privatePatterns {
|
return true
|
||||||
if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern {
|
}
|
||||||
|
|
||||||
|
if ip := net.ParseIP(hostLower); ip != nil {
|
||||||
|
return isPrivateIPAddr(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := net.LookupIP(hostLower)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ip := range ips {
|
||||||
|
if isPrivateIPAddr(ip) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also block .local domains
|
return false
|
||||||
if len(host) > 6 && host[len(host)-6:] == ".local" {
|
}
|
||||||
|
|
||||||
|
func isPrivateIPAddr(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ip.IsLoopback() ||
|
||||||
|
ip.IsPrivate() ||
|
||||||
|
ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsLinkLocalMulticast() ||
|
||||||
|
ip.IsMulticast() ||
|
||||||
|
ip.IsUnspecified() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !ip.IsGlobalUnicast() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,18 +222,16 @@ func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
|||||||
r.settings = settings
|
r.settings = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterAPIs registers all sandboxed APIs to the Goja VM
|
|
||||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||||
r.vm = vm
|
r.vm = vm
|
||||||
|
|
||||||
// HTTP client (sandboxed to allowed domains)
|
|
||||||
httpObj := vm.NewObject()
|
httpObj := vm.NewObject()
|
||||||
httpObj.Set("get", r.httpGet)
|
httpObj.Set("get", r.httpGet)
|
||||||
httpObj.Set("post", r.httpPost)
|
httpObj.Set("post", r.httpPost)
|
||||||
httpObj.Set("put", r.httpPut)
|
httpObj.Set("put", r.httpPut)
|
||||||
httpObj.Set("delete", r.httpDelete)
|
httpObj.Set("delete", r.httpDelete)
|
||||||
httpObj.Set("patch", r.httpPatch)
|
httpObj.Set("patch", r.httpPatch)
|
||||||
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
httpObj.Set("request", r.httpRequest)
|
||||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||||
vm.Set("http", httpObj)
|
vm.Set("http", httpObj)
|
||||||
|
|
||||||
@@ -222,7 +241,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
storageObj.Set("remove", r.storageRemove)
|
storageObj.Set("remove", r.storageRemove)
|
||||||
vm.Set("storage", storageObj)
|
vm.Set("storage", storageObj)
|
||||||
|
|
||||||
// Secure Credentials API (encrypted storage for sensitive data)
|
|
||||||
credentialsObj := vm.NewObject()
|
credentialsObj := vm.NewObject()
|
||||||
credentialsObj.Set("store", r.credentialsStore)
|
credentialsObj.Set("store", r.credentialsStore)
|
||||||
credentialsObj.Set("get", r.credentialsGet)
|
credentialsObj.Set("get", r.credentialsGet)
|
||||||
@@ -237,14 +255,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
authObj.Set("clearAuth", r.authClear)
|
authObj.Set("clearAuth", r.authClear)
|
||||||
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||||
authObj.Set("getTokens", r.authGetTokens)
|
authObj.Set("getTokens", r.authGetTokens)
|
||||||
// PKCE support
|
|
||||||
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||||
authObj.Set("getPKCE", r.authGetPKCE)
|
authObj.Set("getPKCE", r.authGetPKCE)
|
||||||
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||||
vm.Set("auth", authObj)
|
vm.Set("auth", authObj)
|
||||||
|
|
||||||
// File operations (sandboxed)
|
|
||||||
fileObj := vm.NewObject()
|
fileObj := vm.NewObject()
|
||||||
fileObj.Set("download", r.fileDownload)
|
fileObj.Set("download", r.fileDownload)
|
||||||
fileObj.Set("exists", r.fileExists)
|
fileObj.Set("exists", r.fileExists)
|
||||||
@@ -262,7 +278,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||||
vm.Set("ffmpeg", ffmpegObj)
|
vm.Set("ffmpeg", ffmpegObj)
|
||||||
|
|
||||||
// Track matching API
|
|
||||||
matchingObj := vm.NewObject()
|
matchingObj := vm.NewObject()
|
||||||
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||||
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||||
@@ -279,14 +294,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
||||||
utilsObj.Set("parseJSON", r.parseJSON)
|
utilsObj.Set("parseJSON", r.parseJSON)
|
||||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||||
// Crypto utilities for developers
|
|
||||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
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)
|
||||||
|
|
||||||
// Log object (already set in extension_manager.go, but we can enhance it)
|
|
||||||
logObj := vm.NewObject()
|
logObj := vm.NewObject()
|
||||||
logObj.Set("debug", r.logDebug)
|
logObj.Set("debug", r.logDebug)
|
||||||
logObj.Set("info", r.logInfo)
|
logObj.Set("info", r.logInfo)
|
||||||
@@ -298,10 +311,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||||
vm.Set("gobackend", gobackendObj)
|
vm.Set("gobackend", gobackendObj)
|
||||||
|
|
||||||
// ==================== Browser-like Polyfills ====================
|
|
||||||
// These make porting browser/Node.js libraries easier
|
|
||||||
|
|
||||||
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
|
||||||
vm.Set("fetch", r.fetchPolyfill)
|
vm.Set("fetch", r.fetchPolyfill)
|
||||||
|
|
||||||
vm.Set("atob", r.atobPolyfill)
|
vm.Set("atob", r.atobPolyfill)
|
||||||
|
|||||||
@@ -18,6 +18,43 @@ import (
|
|||||||
|
|
||||||
// ==================== Auth API (OAuth Support) ====================
|
// ==================== Auth API (OAuth Support) ====================
|
||||||
|
|
||||||
|
func validateExtensionAuthURL(urlStr string) error {
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid auth URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("invalid auth URL: only https is allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
host := parsed.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return fmt.Errorf("invalid auth URL: hostname is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.User != nil {
|
||||||
|
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPrivateIP(host) {
|
||||||
|
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeURLForLog(urlStr string) string {
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return urlStr
|
||||||
|
}
|
||||||
|
if parsed.Host == "" {
|
||||||
|
return parsed.Scheme + "://"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
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{}{
|
||||||
@@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
callbackURL = call.Arguments[1].String()
|
callbackURL = call.Arguments[1].String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pendingAuthRequestsMu.Lock()
|
pendingAuthRequestsMu.Lock()
|
||||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
ExtensionID: r.extensionID,
|
ExtensionID: r.extensionID,
|
||||||
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
state.AuthCode = ""
|
state.AuthCode = ""
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -70,13 +114,11 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(state.AuthCode)
|
return r.vm.ToValue(state.AuthCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
|
|
||||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can accept either just auth code or an object with tokens
|
|
||||||
arg := call.Arguments[0].Export()
|
arg := call.Arguments[0].Export()
|
||||||
|
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
@@ -123,7 +165,6 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authIsAuthenticated checks if extension has valid auth
|
|
||||||
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()
|
||||||
@@ -196,7 +237,6 @@ func generatePKCEChallenge(verifier string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||||
// Default length is 64 characters
|
|
||||||
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 {
|
||||||
@@ -249,9 +289,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
|
|
||||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||||
// Returns: { success, authUrl, pkce: { verifier, challenge } }
|
|
||||||
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{}{
|
||||||
@@ -269,7 +307,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required fields
|
|
||||||
authURL, _ := config["authUrl"].(string)
|
authURL, _ := config["authUrl"].(string)
|
||||||
clientID, _ := config["clientId"].(string)
|
clientID, _ := config["clientId"].(string)
|
||||||
redirectURI, _ := config["redirectUri"].(string)
|
redirectURI, _ := config["redirectUri"].(string)
|
||||||
@@ -280,12 +317,16 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
"error": "authUrl, clientId, and redirectUri are required",
|
"error": "authUrl, clientId, and redirectUri are required",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Optional fields
|
|
||||||
scope, _ := config["scope"].(string)
|
scope, _ := config["scope"].(string)
|
||||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||||
|
|
||||||
// Generate PKCE
|
|
||||||
verifier, err := generatePKCEVerifier(64)
|
verifier, err := generatePKCEVerifier(64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -295,7 +336,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
}
|
}
|
||||||
challenge := generatePKCEChallenge(verifier)
|
challenge := generatePKCEChallenge(verifier)
|
||||||
|
|
||||||
// Store PKCE in auth state
|
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
state, exists := extensionAuthState[r.extensionID]
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -304,10 +344,9 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
}
|
}
|
||||||
state.PKCEVerifier = verifier
|
state.PKCEVerifier = verifier
|
||||||
state.PKCEChallenge = challenge
|
state.PKCEChallenge = challenge
|
||||||
state.AuthCode = "" // Clear any previous auth code
|
state.AuthCode = ""
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
// Build OAuth URL with PKCE parameters
|
|
||||||
parsedURL, err := url.Parse(authURL)
|
parsedURL, err := url.Parse(authURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -327,7 +366,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
query.Set("scope", scope)
|
query.Set("scope", scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add extra params
|
|
||||||
for k, v := range extraParams {
|
for k, v := range extraParams {
|
||||||
query.Set(k, fmt.Sprintf("%v", v))
|
query.Set(k, fmt.Sprintf("%v", v))
|
||||||
}
|
}
|
||||||
@@ -335,7 +373,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
parsedURL.RawQuery = query.Encode()
|
parsedURL.RawQuery = query.Encode()
|
||||||
fullAuthURL := parsedURL.String()
|
fullAuthURL := parsedURL.String()
|
||||||
|
|
||||||
// Store pending auth request for Flutter
|
|
||||||
pendingAuthRequestsMu.Lock()
|
pendingAuthRequestsMu.Lock()
|
||||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
ExtensionID: r.extensionID,
|
ExtensionID: r.extensionID,
|
||||||
@@ -344,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
}
|
}
|
||||||
pendingAuthRequestsMu.Unlock()
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -454,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||||
|
if len(bodyPreview) > 1000 {
|
||||||
|
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp map[string]interface{}
|
var tokenResp map[string]interface{}
|
||||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
if err := json.Unmarshal(body, &tokenResp); 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 parse token response: %v", err),
|
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||||
"body": string(body),
|
"body": bodyPreview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "no access_token in response",
|
"error": "no access_token in response",
|
||||||
"body": string(body),
|
"body": bodyPreview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
command := call.Arguments[0].String()
|
command := call.Arguments[0].String()
|
||||||
|
|
||||||
// Generate unique command ID
|
|
||||||
ffmpegCommandsMu.Lock()
|
ffmpegCommandsMu.Lock()
|
||||||
ffmpegCommandID++
|
ffmpegCommandID++
|
||||||
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||||
@@ -77,7 +76,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||||
|
|
||||||
// Wait for completion (with timeout)
|
|
||||||
timeout := 5 * time.Minute
|
timeout := 5 * time.Minute
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
for {
|
for {
|
||||||
@@ -97,7 +95,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
ffmpegCommandsMu.RUnlock()
|
ffmpegCommandsMu.RUnlock()
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
ClearFFmpegCommand(cmdID)
|
ClearFFmpegCommand(cmdID)
|
||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
@@ -124,7 +121,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
filePath := call.Arguments[0].String()
|
filePath := call.Arguments[0].String()
|
||||||
|
|
||||||
// Use Go's built-in audio quality function
|
|
||||||
quality, err := GetAudioQuality(filePath)
|
quality, err := GetAudioQuality(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -153,7 +149,6 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
|||||||
inputPath := call.Arguments[0].String()
|
inputPath := call.Arguments[0].String()
|
||||||
outputPath := call.Arguments[1].String()
|
outputPath := call.Arguments[1].String()
|
||||||
|
|
||||||
// Get options if provided
|
|
||||||
options := map[string]interface{}{}
|
options := map[string]interface{}{}
|
||||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||||
@@ -161,36 +156,29 @@ func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build FFmpeg command
|
|
||||||
var cmdParts []string
|
var cmdParts []string
|
||||||
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||||
|
|
||||||
// Audio codec
|
|
||||||
if codec, ok := options["codec"].(string); ok {
|
if codec, ok := options["codec"].(string); ok {
|
||||||
cmdParts = append(cmdParts, "-c:a", codec)
|
cmdParts = append(cmdParts, "-c:a", codec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bitrate
|
|
||||||
if bitrate, ok := options["bitrate"].(string); ok {
|
if bitrate, ok := options["bitrate"].(string); ok {
|
||||||
cmdParts = append(cmdParts, "-b:a", bitrate)
|
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sample rate
|
|
||||||
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||||
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels
|
|
||||||
if channels, ok := options["channels"].(float64); ok {
|
if channels, ok := options["channels"].(float64); ok {
|
||||||
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overwrite output
|
|
||||||
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||||
|
|
||||||
command := strings.Join(cmdParts, " ")
|
command := strings.Join(cmdParts, " ")
|
||||||
|
|
||||||
// Execute via ffmpegExecute
|
|
||||||
execCall := goja.FunctionCall{
|
execCall := goja.FunctionCall{
|
||||||
Arguments: []goja.Value{r.vm.ToValue(command)},
|
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
|
|
||||||
// ==================== File API (Sandboxed) ====================
|
// ==================== File API (Sandboxed) ====================
|
||||||
|
|
||||||
// List of allowed directories for file operations (set by Go backend for download operations)
|
|
||||||
var (
|
var (
|
||||||
allowedDownloadDirs []string
|
allowedDownloadDirs []string
|
||||||
allowedDownloadDirsMu sync.RWMutex
|
allowedDownloadDirsMu sync.RWMutex
|
||||||
@@ -42,18 +41,40 @@ func isPathInAllowedDirs(absPath string) bool {
|
|||||||
defer allowedDownloadDirsMu.RUnlock()
|
defer allowedDownloadDirsMu.RUnlock()
|
||||||
|
|
||||||
for _, allowedDir := range allowedDownloadDirs {
|
for _, allowedDir := range allowedDownloadDirs {
|
||||||
if strings.HasPrefix(absPath, allowedDir) {
|
if isPathWithinBase(allowedDir, absPath) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// validatePath checks if the path is within the extension's sandbox
|
func isPathWithinBase(baseDir, targetPath string) bool {
|
||||||
// Security: Absolute paths are BLOCKED unless they're in allowed download directories
|
baseAbs, err := filepath.Abs(baseDir)
|
||||||
// Extensions should use relative paths for their own data storage
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
targetAbs, err := filepath.Abs(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(baseAbs, targetAbs)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel = filepath.Clean(rel)
|
||||||
|
if rel == "." {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := ".." + string(filepath.Separator)
|
||||||
|
if rel == ".." || strings.HasPrefix(rel, prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||||
// Check if extension has file permission
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -81,7 +102,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||||
if !strings.HasPrefix(absPath, absDataDir) {
|
if !isPathWithinBase(absDataDir, absPath) {
|
||||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +348,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create directory if needed
|
|
||||||
dir := filepath.Dir(fullPath)
|
dir := filepath.Dir(fullPath)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
|||||||
@@ -14,23 +14,33 @@ import (
|
|||||||
|
|
||||||
// ==================== HTTP API (Sandboxed) ====================
|
// ==================== HTTP API (Sandboxed) ====================
|
||||||
|
|
||||||
// HTTPResponse represents the response from an HTTP request
|
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateDomain checks if the domain is allowed by the extension's permissions
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
domain := parsed.Hostname()
|
if parsed.Scheme == "" {
|
||||||
|
return fmt.Errorf("invalid URL: scheme is required")
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("network access denied: only https is allowed")
|
||||||
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := parsed.Hostname()
|
||||||
|
if domain == "" {
|
||||||
|
return fmt.Errorf("invalid URL: hostname is required")
|
||||||
|
}
|
||||||
|
|
||||||
// Block private/local network access (SSRF protection)
|
|
||||||
if isPrivateIP(domain) {
|
if isPrivateIP(domain) {
|
||||||
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||||
}
|
}
|
||||||
@@ -42,7 +52,6 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpGet performs a GET request (sandboxed)
|
|
||||||
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{}{
|
||||||
@@ -76,16 +85,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers - user headers first
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
// Only set default User-Agent if not provided by extension
|
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -101,26 +108,24 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
|
|
||||||
respHeaders := make(map[string]interface{})
|
respHeaders := make(map[string]interface{})
|
||||||
for k, v := range resp.Header {
|
for k, v := range resp.Header {
|
||||||
if len(v) == 1 {
|
if len(v) == 1 {
|
||||||
respHeaders[k] = v[0]
|
respHeaders[k] = v[0]
|
||||||
} else {
|
} else {
|
||||||
respHeaders[k] = v // Return as array if multiple values
|
respHeaders[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode, // Alias for convenience
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpPost performs a POST request (sandboxed)
|
|
||||||
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{}{
|
||||||
@@ -137,7 +142,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get body if provided - support both string and object
|
|
||||||
var bodyStr string
|
var bodyStr string
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
bodyArg := call.Arguments[1].Export()
|
bodyArg := call.Arguments[1].Export()
|
||||||
@@ -145,7 +149,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
case string:
|
case string:
|
||||||
bodyStr = v
|
bodyStr = v
|
||||||
case map[string]interface{}, []interface{}:
|
case map[string]interface{}, []interface{}:
|
||||||
// Auto-stringify objects and arrays to JSON
|
|
||||||
jsonBytes, err := json.Marshal(v)
|
jsonBytes, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -154,12 +157,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
bodyStr = string(jsonBytes)
|
bodyStr = string(jsonBytes)
|
||||||
default:
|
default:
|
||||||
// Fallback to string conversion
|
|
||||||
bodyStr = call.Arguments[1].String()
|
bodyStr = call.Arguments[1].String()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get headers if provided
|
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
headersObj := call.Arguments[2].Export()
|
headersObj := call.Arguments[2].Export()
|
||||||
@@ -177,11 +178,10 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers - user headers first
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
// Only set defaults if not provided by extension
|
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -205,19 +204,18 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract response headers - return all values as arrays for multi-value headers
|
|
||||||
respHeaders := make(map[string]interface{})
|
respHeaders := make(map[string]interface{})
|
||||||
for k, v := range resp.Header {
|
for k, v := range resp.Header {
|
||||||
if len(v) == 1 {
|
if len(v) == 1 {
|
||||||
respHeaders[k] = v[0]
|
respHeaders[k] = v[0]
|
||||||
} else {
|
} else {
|
||||||
respHeaders[k] = v // Return as array if multiple values
|
respHeaders[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode, // Alias for convenience
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
@@ -240,27 +238,22 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default options
|
|
||||||
method := "GET"
|
method := "GET"
|
||||||
var bodyStr string
|
var bodyStr string
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
// Parse options if provided
|
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
optionsObj := call.Arguments[1].Export()
|
optionsObj := call.Arguments[1].Export()
|
||||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
// Get method
|
|
||||||
if m, ok := opts["method"].(string); ok {
|
if m, ok := opts["method"].(string); ok {
|
||||||
method = strings.ToUpper(m)
|
method = strings.ToUpper(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get body - support both string and object
|
|
||||||
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:
|
||||||
bodyStr = v
|
bodyStr = v
|
||||||
case map[string]interface{}, []interface{}:
|
case map[string]interface{}, []interface{}:
|
||||||
// Auto-stringify objects and arrays to JSON
|
|
||||||
jsonBytes, err := json.Marshal(v)
|
jsonBytes, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -273,7 +266,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get headers
|
|
||||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||||
for k, v := range h {
|
for k, v := range h {
|
||||||
headers[k] = fmt.Sprintf("%v", v)
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
@@ -282,7 +274,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create request
|
|
||||||
var reqBody io.Reader
|
var reqBody io.Reader
|
||||||
if bodyStr != "" {
|
if bodyStr != "" {
|
||||||
reqBody = strings.NewReader(bodyStr)
|
reqBody = strings.NewReader(bodyStr)
|
||||||
@@ -295,11 +286,10 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers - user headers first
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
// Only set defaults if not provided by extension
|
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||||
}
|
}
|
||||||
@@ -307,7 +297,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -323,20 +312,18 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract response headers - return all values as arrays for multi-value headers
|
|
||||||
respHeaders := make(map[string]interface{})
|
respHeaders := make(map[string]interface{})
|
||||||
for k, v := range resp.Header {
|
for k, v := range resp.Header {
|
||||||
if len(v) == 1 {
|
if len(v) == 1 {
|
||||||
respHeaders[k] = v[0]
|
respHeaders[k] = v[0]
|
||||||
} else {
|
} else {
|
||||||
respHeaders[k] = v // Return as array if multiple values
|
respHeaders[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return response with helper properties
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"statusCode": resp.StatusCode,
|
"statusCode": resp.StatusCode,
|
||||||
"status": resp.StatusCode, // Alias for convenience
|
"status": resp.StatusCode,
|
||||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
"body": string(body),
|
"body": string(body),
|
||||||
"headers": respHeaders,
|
"headers": respHeaders,
|
||||||
@@ -347,7 +334,6 @@ func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
|||||||
return r.httpMethodShortcut("PUT", call)
|
return r.httpMethodShortcut("PUT", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
|
|
||||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("DELETE", call)
|
return r.httpMethodShortcut("DELETE", call)
|
||||||
}
|
}
|
||||||
@@ -356,8 +342,6 @@ func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
|||||||
return r.httpMethodShortcut("PATCH", call)
|
return r.httpMethodShortcut("PATCH", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
|
|
||||||
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
|
|
||||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
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{}{
|
||||||
@@ -377,9 +361,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
var bodyStr string
|
var bodyStr string
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
|
|
||||||
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
|
|
||||||
if method == "DELETE" {
|
if method == "DELETE" {
|
||||||
// http.delete(url, headers)
|
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
headersObj := call.Arguments[1].Export()
|
headersObj := call.Arguments[1].Export()
|
||||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||||
@@ -389,7 +371,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// http.put(url, body, headers) / http.patch(url, body, headers)
|
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
bodyArg := call.Arguments[1].Export()
|
bodyArg := call.Arguments[1].Export()
|
||||||
switch v := bodyArg.(type) {
|
switch v := bodyArg.(type) {
|
||||||
@@ -418,7 +399,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create request
|
|
||||||
var reqBody io.Reader
|
var reqBody io.Reader
|
||||||
if bodyStr != "" {
|
if bodyStr != "" {
|
||||||
reqBody = strings.NewReader(bodyStr)
|
reqBody = strings.NewReader(bodyStr)
|
||||||
@@ -431,7 +411,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers - user headers first
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
@@ -442,7 +421,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -458,7 +436,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract response headers
|
|
||||||
respHeaders := make(map[string]interface{})
|
respHeaders := make(map[string]interface{})
|
||||||
for k, v := range resp.Header {
|
for k, v := range resp.Header {
|
||||||
if len(v) == 1 {
|
if len(v) == 1 {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
// ==================== Track Matching API ====================
|
// ==================== Track Matching API ====================
|
||||||
|
|
||||||
// matchingCompareStrings compares two strings with fuzzy matching
|
|
||||||
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)
|
||||||
@@ -22,12 +21,10 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
|||||||
return r.vm.ToValue(1.0)
|
return r.vm.ToValue(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate Levenshtein distance-based similarity
|
|
||||||
similarity := calculateStringSimilarity(str1, str2)
|
similarity := calculateStringSimilarity(str1, str2)
|
||||||
return r.vm.ToValue(similarity)
|
return r.vm.ToValue(similarity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchingCompareDuration compares two durations with tolerance
|
|
||||||
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)
|
||||||
@@ -36,8 +33,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
|||||||
dur1 := int(call.Arguments[0].ToInteger())
|
dur1 := int(call.Arguments[0].ToInteger())
|
||||||
dur2 := int(call.Arguments[1].ToInteger())
|
dur2 := int(call.Arguments[1].ToInteger())
|
||||||
|
|
||||||
// Default tolerance: 3 seconds
|
tolerance := 3000
|
||||||
tolerance := 3000 // milliseconds
|
|
||||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||||
tolerance = int(call.Arguments[2].ToInteger())
|
tolerance = int(call.Arguments[2].ToInteger())
|
||||||
}
|
}
|
||||||
@@ -50,7 +46,6 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(diff <= tolerance)
|
return r.vm.ToValue(diff <= tolerance)
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchingNormalizeString normalizes a string for comparison
|
|
||||||
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("")
|
||||||
@@ -61,7 +56,6 @@ func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(normalized)
|
return r.vm.ToValue(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateStringSimilarity calculates similarity between two strings (0-1)
|
|
||||||
func calculateStringSimilarity(s1, s2 string) float64 {
|
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||||
if len(s1) == 0 && len(s2) == 0 {
|
if len(s1) == 0 && len(s2) == 0 {
|
||||||
return 1.0
|
return 1.0
|
||||||
@@ -70,7 +64,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
|||||||
return 0.0
|
return 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Levenshtein distance
|
|
||||||
distance := levenshteinDistance(s1, s2)
|
distance := levenshteinDistance(s1, s2)
|
||||||
maxLen := len(s1)
|
maxLen := len(s1)
|
||||||
if len(s2) > maxLen {
|
if len(s2) > maxLen {
|
||||||
@@ -80,7 +73,6 @@ func calculateStringSimilarity(s1, s2 string) float64 {
|
|||||||
return 1.0 - float64(distance)/float64(maxLen)
|
return 1.0 - float64(distance)/float64(maxLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
|
||||||
func levenshteinDistance(s1, s2 string) int {
|
func levenshteinDistance(s1, s2 string) int {
|
||||||
if len(s1) == 0 {
|
if len(s1) == 0 {
|
||||||
return len(s2)
|
return len(s2)
|
||||||
@@ -89,7 +81,6 @@ func levenshteinDistance(s1, s2 string) int {
|
|||||||
return len(s1)
|
return len(s1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create matrix
|
|
||||||
matrix := make([][]int, len(s1)+1)
|
matrix := make([][]int, len(s1)+1)
|
||||||
for i := range matrix {
|
for i := range matrix {
|
||||||
matrix[i] = make([]int, len(s2)+1)
|
matrix[i] = make([]int, len(s2)+1)
|
||||||
@@ -99,7 +90,6 @@ func levenshteinDistance(s1, s2 string) int {
|
|||||||
matrix[0][j] = j
|
matrix[0][j] = j
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill matrix
|
|
||||||
for i := 1; i <= len(s1); i++ {
|
for i := 1; i <= len(s1); i++ {
|
||||||
for j := 1; j <= len(s2); j++ {
|
for j := 1; j <= len(s2); j++ {
|
||||||
cost := 1
|
cost := 1
|
||||||
@@ -107,9 +97,9 @@ func levenshteinDistance(s1, s2 string) int {
|
|||||||
cost = 0
|
cost = 0
|
||||||
}
|
}
|
||||||
matrix[i][j] = min(
|
matrix[i][j] = min(
|
||||||
matrix[i-1][j]+1, // deletion
|
matrix[i-1][j]+1,
|
||||||
matrix[i][j-1]+1, // insertion
|
matrix[i][j-1]+1,
|
||||||
matrix[i-1][j-1]+cost, // substitution
|
matrix[i-1][j-1]+cost,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,12 +107,9 @@ func levenshteinDistance(s1, s2 string) int {
|
|||||||
return matrix[len(s1)][len(s2)]
|
return matrix[len(s1)][len(s2)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeStringForMatching normalizes a string for comparison
|
|
||||||
func normalizeStringForMatching(s string) string {
|
func normalizeStringForMatching(s string) string {
|
||||||
// Convert to lowercase
|
|
||||||
s = strings.ToLower(s)
|
s = strings.ToLower(s)
|
||||||
|
|
||||||
// Remove common suffixes/prefixes
|
|
||||||
suffixes := []string{
|
suffixes := []string{
|
||||||
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||||
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||||
@@ -136,7 +123,6 @@ func normalizeStringForMatching(s string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove special characters
|
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||||
@@ -144,7 +130,6 @@ func normalizeStringForMatching(s string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse multiple spaces
|
|
||||||
s = strings.Join(strings.Fields(result.String()), " ")
|
s = strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
|
||||||
return strings.TrimSpace(s)
|
return strings.TrimSpace(s)
|
||||||
|
|||||||
@@ -25,14 +25,11 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
urlStr := call.Arguments[0].String()
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
// Validate domain
|
|
||||||
if err := r.validateDomain(urlStr); err != nil {
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||||
return r.createFetchError(err.Error())
|
return r.createFetchError(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse options
|
|
||||||
method := "GET"
|
method := "GET"
|
||||||
var bodyStr string
|
var bodyStr string
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]string)
|
||||||
@@ -40,7 +37,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||||
optionsObj := call.Arguments[1].Export()
|
optionsObj := call.Arguments[1].Export()
|
||||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
// Method
|
|
||||||
if m, ok := opts["method"].(string); ok {
|
if m, ok := opts["method"].(string); ok {
|
||||||
method = strings.ToUpper(m)
|
method = strings.ToUpper(m)
|
||||||
}
|
}
|
||||||
@@ -61,7 +57,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Headers
|
|
||||||
if h, ok := opts["headers"]; ok && h != nil {
|
if h, ok := opts["headers"]; ok && h != nil {
|
||||||
switch hv := h.(type) {
|
switch hv := h.(type) {
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
@@ -73,7 +68,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create HTTP request
|
|
||||||
var reqBody io.Reader
|
var reqBody io.Reader
|
||||||
if bodyStr != "" {
|
if bodyStr != "" {
|
||||||
reqBody = strings.NewReader(bodyStr)
|
reqBody = strings.NewReader(bodyStr)
|
||||||
@@ -84,11 +78,9 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.createFetchError(err.Error())
|
return r.createFetchError(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers - user headers first
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
// Set defaults if not provided
|
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
}
|
}
|
||||||
@@ -96,20 +88,17 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.createFetchError(err.Error())
|
return r.createFetchError(err.Error())
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Read body
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.createFetchError(err.Error())
|
return r.createFetchError(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract response headers
|
|
||||||
respHeaders := make(map[string]interface{})
|
respHeaders := make(map[string]interface{})
|
||||||
for k, v := range resp.Header {
|
for k, v := range resp.Header {
|
||||||
if len(v) == 1 {
|
if len(v) == 1 {
|
||||||
@@ -119,7 +108,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Response object (browser-compatible)
|
|
||||||
responseObj := r.vm.NewObject()
|
responseObj := r.vm.NewObject()
|
||||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||||
responseObj.Set("status", resp.StatusCode)
|
responseObj.Set("status", resp.StatusCode)
|
||||||
@@ -127,15 +115,12 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
responseObj.Set("headers", respHeaders)
|
responseObj.Set("headers", respHeaders)
|
||||||
responseObj.Set("url", urlStr)
|
responseObj.Set("url", urlStr)
|
||||||
|
|
||||||
// Store body for methods
|
|
||||||
bodyString := string(body)
|
bodyString := string(body)
|
||||||
|
|
||||||
// text() method - returns body as string
|
|
||||||
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||||
return r.vm.ToValue(bodyString)
|
return r.vm.ToValue(bodyString)
|
||||||
})
|
})
|
||||||
|
|
||||||
// json() method - parses body as JSON
|
|
||||||
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||||
var result interface{}
|
var result interface{}
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
@@ -145,9 +130,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
// arrayBuffer() method - returns body as array (simplified)
|
|
||||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||||
// Return as array of bytes
|
|
||||||
byteArray := make([]interface{}, len(body))
|
byteArray := make([]interface{}, len(body))
|
||||||
for i, b := range body {
|
for i, b := range body {
|
||||||
byteArray[i] = int(b)
|
byteArray[i] = int(b)
|
||||||
@@ -182,7 +165,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
input := call.Arguments[0].String()
|
input := call.Arguments[0].String()
|
||||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try URL-safe base64
|
|
||||||
decoded, err = base64.URLEncoding.DecodeString(input)
|
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||||
@@ -203,12 +185,10 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
// TextEncoder constructor
|
|
||||||
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")
|
||||||
|
|
||||||
// encode() method - string to Uint8Array
|
|
||||||
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return vm.ToValue([]byte{})
|
return vm.ToValue([]byte{})
|
||||||
@@ -216,7 +196,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
input := call.Arguments[0].String()
|
input := call.Arguments[0].String()
|
||||||
bytes := []byte(input)
|
bytes := []byte(input)
|
||||||
|
|
||||||
// Return as array (Uint8Array-like)
|
|
||||||
result := make([]interface{}, len(bytes))
|
result := make([]interface{}, len(bytes))
|
||||||
for i, b := range bytes {
|
for i, b := range bytes {
|
||||||
result[i] = int(b)
|
result[i] = int(b)
|
||||||
@@ -224,7 +203,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
return vm.ToValue(result)
|
return vm.ToValue(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
// encodeInto() method
|
|
||||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||||
// Simplified implementation
|
// Simplified implementation
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
@@ -240,11 +218,9 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// TextDecoder constructor
|
|
||||||
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
decoder := call.This
|
decoder := call.This
|
||||||
|
|
||||||
// Get encoding from arguments (default: utf-8)
|
|
||||||
encoding := "utf-8"
|
encoding := "utf-8"
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
encoding = call.Arguments[0].String()
|
encoding = call.Arguments[0].String()
|
||||||
@@ -253,13 +229,11 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
decoder.Set("fatal", false)
|
decoder.Set("fatal", false)
|
||||||
decoder.Set("ignoreBOM", false)
|
decoder.Set("ignoreBOM", false)
|
||||||
|
|
||||||
// decode() method - Uint8Array to string
|
|
||||||
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return vm.ToValue("")
|
return vm.ToValue("")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different input types
|
|
||||||
input := call.Arguments[0].Export()
|
input := call.Arguments[0].Export()
|
||||||
var bytes []byte
|
var bytes []byte
|
||||||
|
|
||||||
@@ -279,7 +253,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case string:
|
case string:
|
||||||
// Already a string, just return it
|
|
||||||
return vm.ToValue(v)
|
return vm.ToValue(v)
|
||||||
default:
|
default:
|
||||||
return vm.ToValue("")
|
return vm.ToValue("")
|
||||||
@@ -292,7 +265,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerURLClass registers the URL class for URL parsing
|
|
||||||
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
|
||||||
@@ -304,7 +276,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
|
|
||||||
urlStr := call.Arguments[0].String()
|
urlStr := call.Arguments[0].String()
|
||||||
|
|
||||||
// Handle relative URLs with base
|
|
||||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||||
baseStr := call.Arguments[1].String()
|
baseStr := call.Arguments[1].String()
|
||||||
baseURL, err := url.Parse(baseStr)
|
baseURL, err := url.Parse(baseStr)
|
||||||
@@ -322,7 +293,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set URL properties
|
|
||||||
urlObj.Set("href", parsed.String())
|
urlObj.Set("href", parsed.String())
|
||||||
urlObj.Set("protocol", parsed.Scheme+":")
|
urlObj.Set("protocol", parsed.Scheme+":")
|
||||||
urlObj.Set("host", parsed.Host)
|
urlObj.Set("host", parsed.Host)
|
||||||
@@ -342,10 +312,9 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
password, _ := parsed.User.Password()
|
password, _ := parsed.User.Password()
|
||||||
urlObj.Set("password", password)
|
urlObj.Set("password", password)
|
||||||
|
|
||||||
// searchParams object
|
|
||||||
searchParams := vm.NewObject()
|
|
||||||
queryValues := parsed.Query()
|
queryValues := parsed.Query()
|
||||||
|
|
||||||
|
searchParams := vm.NewObject()
|
||||||
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Null()
|
return goja.Null()
|
||||||
@@ -379,12 +348,10 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
|
|
||||||
urlObj.Set("searchParams", searchParams)
|
urlObj.Set("searchParams", searchParams)
|
||||||
|
|
||||||
// toString method
|
|
||||||
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||||
return vm.ToValue(parsed.String())
|
return vm.ToValue(parsed.String())
|
||||||
})
|
})
|
||||||
|
|
||||||
// toJSON method
|
|
||||||
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||||
return vm.ToValue(parsed.String())
|
return vm.ToValue(parsed.String())
|
||||||
})
|
})
|
||||||
@@ -392,17 +359,14 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// URLSearchParams constructor
|
|
||||||
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||||
paramsObj := call.This
|
paramsObj := call.This
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
|
|
||||||
// Parse initial value if provided
|
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
init := call.Arguments[0].Export()
|
init := call.Arguments[0].Export()
|
||||||
switch v := init.(type) {
|
switch v := init.(type) {
|
||||||
case string:
|
case string:
|
||||||
// Parse query string
|
|
||||||
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||||
values = parsed
|
values = parsed
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
@@ -468,10 +432,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
// registerJSONGlobal ensures JSON global is properly set up
|
// 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
|
// JSON is already built-in to Goja, but we can enhance it
|
||||||
// This ensures JSON.parse and JSON.stringify work as expected
|
|
||||||
|
|
||||||
// The built-in JSON object should already work, but let's verify
|
|
||||||
// and add any missing functionality if needed
|
|
||||||
jsonScript := `
|
jsonScript := `
|
||||||
if (typeof JSON === 'undefined') {
|
if (typeof JSON === 'undefined') {
|
||||||
var JSON = {
|
var JSON = {
|
||||||
|
|||||||
@@ -17,12 +17,10 @@ import (
|
|||||||
|
|
||||||
// ==================== Storage API ====================
|
// ==================== Storage API ====================
|
||||||
|
|
||||||
// getStoragePath returns the path to the extension's storage file
|
|
||||||
func (r *ExtensionRuntime) getStoragePath() string {
|
func (r *ExtensionRuntime) getStoragePath() string {
|
||||||
return filepath.Join(r.dataDir, "storage.json")
|
return filepath.Join(r.dataDir, "storage.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadStorage loads the storage data from disk
|
|
||||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||||
storagePath := r.getStoragePath()
|
storagePath := r.getStoragePath()
|
||||||
data, err := os.ReadFile(storagePath)
|
data, err := os.ReadFile(storagePath)
|
||||||
@@ -41,7 +39,6 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
|||||||
return storage, nil
|
return storage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveStorage saves the storage data to disk
|
|
||||||
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||||
storagePath := r.getStoragePath()
|
storagePath := r.getStoragePath()
|
||||||
data, err := json.MarshalIndent(storage, "", " ")
|
data, err := json.MarshalIndent(storage, "", " ")
|
||||||
@@ -49,10 +46,9 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(storagePath, data, 0644)
|
return os.WriteFile(storagePath, data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageGet retrieves a value from storage
|
|
||||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
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()
|
||||||
@@ -68,7 +64,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
value, exists := storage[key]
|
value, exists := storage[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
// Return default value if provided
|
|
||||||
if len(call.Arguments) > 1 {
|
if len(call.Arguments) > 1 {
|
||||||
return call.Arguments[1]
|
return call.Arguments[1]
|
||||||
}
|
}
|
||||||
@@ -78,7 +73,6 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageSet stores a value in storage
|
|
||||||
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)
|
||||||
@@ -103,7 +97,6 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storageRemove removes a value from storage
|
|
||||||
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)
|
||||||
@@ -127,19 +120,14 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Credentials API (Encrypted Storage) ====================
|
|
||||||
|
|
||||||
// getCredentialsPath returns the path to the extension's encrypted credentials file
|
|
||||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSaltPath returns the path to the extension's encryption salt file
|
|
||||||
func (r *ExtensionRuntime) getSaltPath() string {
|
func (r *ExtensionRuntime) getSaltPath() string {
|
||||||
return filepath.Join(r.dataDir, ".cred_salt")
|
return filepath.Join(r.dataDir, ".cred_salt")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOrCreateSalt gets existing salt or creates a new random one
|
|
||||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||||
saltPath := r.getSaltPath()
|
saltPath := r.getSaltPath()
|
||||||
|
|
||||||
@@ -160,22 +148,17 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
|||||||
return salt, nil
|
return salt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEncryptionKey derives an encryption key from extension ID + random salt
|
|
||||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||||
// Get or create per-installation random salt
|
|
||||||
salt, err := r.getOrCreateSalt()
|
salt, err := r.getOrCreateSalt()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine extension ID + random salt for key derivation
|
|
||||||
// This makes each installation unique, preventing mass decryption attacks
|
|
||||||
combined := append([]byte(r.extensionID), salt...)
|
combined := append([]byte(r.extensionID), salt...)
|
||||||
hash := sha256.Sum256(combined)
|
hash := sha256.Sum256(combined)
|
||||||
return hash[:], nil
|
return hash[:], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCredentials loads and decrypts credentials from disk
|
|
||||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||||
credPath := r.getCredentialsPath()
|
credPath := r.getCredentialsPath()
|
||||||
data, err := os.ReadFile(credPath)
|
data, err := os.ReadFile(credPath)
|
||||||
@@ -186,7 +169,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the data
|
|
||||||
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 nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||||
@@ -204,7 +186,6 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
|||||||
return creds, nil
|
return creds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveCredentials encrypts and saves credentials to disk
|
|
||||||
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 {
|
||||||
@@ -221,10 +202,9 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
credPath := r.getCredentialsPath()
|
credPath := r.getCredentialsPath()
|
||||||
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions
|
return os.WriteFile(credPath, encrypted, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialsStore stores an encrypted credential
|
|
||||||
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{}{
|
||||||
@@ -260,7 +240,6 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialsGet retrieves a decrypted credential
|
|
||||||
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()
|
||||||
@@ -276,7 +255,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
value, exists := creds[key]
|
value, exists := creds[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
// Return default value if provided
|
|
||||||
if len(call.Arguments) > 1 {
|
if len(call.Arguments) > 1 {
|
||||||
return call.Arguments[1]
|
return call.Arguments[1]
|
||||||
}
|
}
|
||||||
@@ -286,7 +264,6 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialsRemove removes a credential
|
|
||||||
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)
|
||||||
@@ -310,7 +287,6 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// credentialsHas checks if a credential exists
|
|
||||||
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)
|
||||||
@@ -327,9 +303,6 @@ func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(exists)
|
return r.vm.ToValue(exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Crypto Utilities ====================
|
|
||||||
|
|
||||||
// encryptAES encrypts data using AES-GCM
|
|
||||||
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -350,7 +323,6 @@ func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
|||||||
return ciphertext, nil
|
return ciphertext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// decryptAES decrypts data using AES-GCM
|
|
||||||
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Utility Functions ====================
|
// ==================== Utility Functions ====================
|
||||||
|
|
||||||
// base64Encode encodes a string to base64
|
|
||||||
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 +27,6 @@ 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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// base64Decode decodes a base64 string
|
|
||||||
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("")
|
||||||
@@ -40,7 +39,6 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
// md5Hash computes MD5 hash of a string
|
|
||||||
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("")
|
||||||
@@ -50,7 +48,6 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// sha256Hash computes SHA256 hash of a string
|
|
||||||
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("")
|
||||||
@@ -60,7 +57,6 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// hmacSHA256 computes HMAC-SHA256 of a message with a key
|
|
||||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
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("")
|
||||||
@@ -73,7 +69,6 @@ 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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
|
|
||||||
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("")
|
||||||
@@ -86,9 +81,6 @@ 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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
|
|
||||||
// Arguments: message (string or array of bytes), key (string or array of bytes)
|
|
||||||
// Returns: array of bytes (for TOTP dynamic truncation)
|
|
||||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
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{})
|
||||||
@@ -141,7 +133,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(jsArray)
|
return r.vm.ToValue(jsArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseJSON parses a JSON string
|
|
||||||
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()
|
||||||
@@ -157,7 +148,6 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// stringifyJSON converts a value to JSON string
|
|
||||||
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("")
|
||||||
@@ -173,9 +163,6 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(data))
|
return r.vm.ToValue(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Crypto Utilities for Extensions ====================
|
|
||||||
|
|
||||||
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
|
|
||||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
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{}{
|
||||||
@@ -187,7 +174,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
|||||||
plaintext := call.Arguments[0].String()
|
plaintext := call.Arguments[0].String()
|
||||||
keyStr := call.Arguments[1].String()
|
keyStr := call.Arguments[1].String()
|
||||||
|
|
||||||
// Derive 32-byte key from provided key string
|
|
||||||
keyHash := sha256.Sum256([]byte(keyStr))
|
keyHash := sha256.Sum256([]byte(keyStr))
|
||||||
|
|
||||||
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||||
@@ -204,7 +190,6 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
|
|
||||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
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{}{
|
||||||
@@ -224,14 +209,13 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive 32-byte key from provided key string
|
|
||||||
keyHash := sha256.Sum256([]byte(keyStr))
|
keyHash := sha256.Sum256([]byte(keyStr))
|
||||||
|
|
||||||
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||||
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": err.Error(),
|
"error": "invalid base64 ciphertext",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,9 +225,8 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// cryptoGenerateKey generates a random encryption key
|
|
||||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||||
length := 32 // Default 256-bit key
|
length := 32
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if 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 {
|
||||||
length = int(l)
|
length = int(l)
|
||||||
@@ -265,13 +248,10 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// randomUserAgent returns a random Chrome User-Agent string
|
|
||||||
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Logging Functions ====================
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -304,8 +284,6 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Go Backend Wrappers ====================
|
|
||||||
|
|
||||||
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("")
|
||||||
@@ -314,7 +292,6 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(sanitizeFilename(input))
|
return r.vm.ToValue(sanitizeFilename(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterGoBackendAPIs adds more Go backend functions to the VM
|
|
||||||
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) {
|
||||||
@@ -324,7 +301,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
|
|
||||||
obj := gobackendObj.(*goja.Object)
|
obj := gobackendObj.(*goja.Object)
|
||||||
|
|
||||||
// Expose sanitizeFilename
|
|
||||||
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return vm.ToValue("")
|
return vm.ToValue("")
|
||||||
@@ -332,7 +308,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expose getAudioQuality
|
|
||||||
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return vm.ToValue(map[string]interface{}{
|
return vm.ToValue(map[string]interface{}{
|
||||||
@@ -355,7 +330,6 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expose buildFilename
|
|
||||||
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return vm.ToValue("")
|
return vm.ToValue("")
|
||||||
@@ -371,4 +345,23 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
|
|
||||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
|
||||||
|
now := time.Now()
|
||||||
|
_, offsetSeconds := now.Zone()
|
||||||
|
offsetMinutes := offsetSeconds / 60
|
||||||
|
|
||||||
|
return vm.ToValue(map[string]interface{}{
|
||||||
|
"year": now.Year(),
|
||||||
|
"month": int(now.Month()),
|
||||||
|
"day": now.Day(),
|
||||||
|
"hour": now.Hour(),
|
||||||
|
"minute": now.Minute(),
|
||||||
|
"second": now.Second(),
|
||||||
|
"weekday": int(now.Weekday()),
|
||||||
|
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
|
||||||
|
"timezone": now.Location().String(),
|
||||||
|
"timestamp": now.Unix(),
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,17 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionSettingsStore manages settings for all extensions
|
|
||||||
type ExtensionSettingsStore struct {
|
type ExtensionSettingsStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
dataDir string
|
dataDir string
|
||||||
settings map[string]map[string]interface{} // extensionID -> settings
|
settings map[string]map[string]interface{} // extensionID -> settings
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global settings store
|
|
||||||
var (
|
var (
|
||||||
globalSettingsStore *ExtensionSettingsStore
|
globalSettingsStore *ExtensionSettingsStore
|
||||||
globalSettingsStoreOnce sync.Once
|
globalSettingsStoreOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetExtensionSettingsStore returns the global settings store
|
|
||||||
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||||
globalSettingsStoreOnce.Do(func() {
|
globalSettingsStoreOnce.Do(func() {
|
||||||
globalSettingsStore = &ExtensionSettingsStore{
|
globalSettingsStore = &ExtensionSettingsStore{
|
||||||
@@ -32,7 +29,6 @@ func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
|||||||
return globalSettingsStore
|
return globalSettingsStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDataDir sets the data directory for settings storage
|
|
||||||
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -45,12 +41,10 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
|||||||
return s.loadAllSettings()
|
return s.loadAllSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSettingsPath returns the path to an extension's settings file
|
|
||||||
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
||||||
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadAllSettings loads settings for all extensions from disk
|
|
||||||
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||||
entries, err := os.ReadDir(s.dataDir)
|
entries, err := os.ReadDir(s.dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,7 +69,6 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadSettings loads settings for a specific extension
|
|
||||||
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||||
settingsPath := s.getSettingsPath(extensionID)
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
data, err := os.ReadFile(settingsPath)
|
data, err := os.ReadFile(settingsPath)
|
||||||
@@ -94,7 +87,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
|
|||||||
return settings, nil
|
return settings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveSettings saves settings for a specific extension
|
|
||||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||||
settingsPath := s.getSettingsPath(extensionID)
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
|
|
||||||
@@ -111,8 +103,6 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
|
|||||||
return os.WriteFile(settingsPath, data, 0644)
|
return os.WriteFile(settingsPath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a setting value for an extension
|
|
||||||
// Returns error if extension or key not found (gomobile compatible)
|
|
||||||
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -129,7 +119,6 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, erro
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll retrieves all settings for an extension
|
|
||||||
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -139,7 +128,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
|
|||||||
return make(map[string]interface{})
|
return make(map[string]interface{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a copy
|
|
||||||
result := make(map[string]interface{})
|
result := make(map[string]interface{})
|
||||||
for k, v := range extSettings {
|
for k, v := range extSettings {
|
||||||
result[k] = v
|
result[k] = v
|
||||||
@@ -147,7 +135,6 @@ func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set stores a setting value for an extension
|
|
||||||
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -161,18 +148,15 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
|
|||||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAll stores all settings for an extension
|
|
||||||
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
s.settings[extensionID] = settings
|
s.settings[extensionID] = settings
|
||||||
|
|
||||||
// Persist to disk
|
|
||||||
return s.saveSettings(extensionID, settings)
|
return s.saveSettings(extensionID, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove removes a setting for an extension
|
|
||||||
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -184,11 +168,9 @@ func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
|||||||
|
|
||||||
delete(extSettings, key)
|
delete(extSettings, key)
|
||||||
|
|
||||||
// Persist to disk
|
|
||||||
return s.saveSettings(extensionID, extSettings)
|
return s.saveSettings(extensionID, extSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveAll removes all settings for an extension
|
|
||||||
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -203,7 +185,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllExtensionSettings returns settings for all extensions as JSON
|
|
||||||
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Extension categories
|
|
||||||
const (
|
const (
|
||||||
CategoryMetadata = "metadata"
|
CategoryMetadata = "metadata"
|
||||||
CategoryDownload = "download"
|
CategoryDownload = "download"
|
||||||
@@ -20,28 +20,26 @@ const (
|
|||||||
CategoryIntegration = "integration"
|
CategoryIntegration = "integration"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StoreExtension represents an extension in the store
|
|
||||||
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"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
IconURL string `json:"icon_url,omitempty"`
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Downloads int `json:"downloads"`
|
Downloads int `json:"downloads"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
|
|
||||||
func (e *StoreExtension) getDisplayName() string {
|
func (e *StoreExtension) getDisplayName() string {
|
||||||
if e.DisplayName != "" {
|
if e.DisplayName != "" {
|
||||||
return e.DisplayName
|
return e.DisplayName
|
||||||
@@ -52,7 +50,6 @@ func (e *StoreExtension) getDisplayName() string {
|
|||||||
return e.Name
|
return e.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
|
|
||||||
func (e *StoreExtension) getDownloadURL() string {
|
func (e *StoreExtension) getDownloadURL() string {
|
||||||
if e.DownloadURL != "" {
|
if e.DownloadURL != "" {
|
||||||
return e.DownloadURL
|
return e.DownloadURL
|
||||||
@@ -60,7 +57,6 @@ func (e *StoreExtension) getDownloadURL() string {
|
|||||||
return e.DownloadURLAlt
|
return e.DownloadURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
|
|
||||||
func (e *StoreExtension) getIconURL() string {
|
func (e *StoreExtension) getIconURL() string {
|
||||||
if e.IconURL != "" {
|
if e.IconURL != "" {
|
||||||
return e.IconURL
|
return e.IconURL
|
||||||
@@ -68,7 +64,6 @@ func (e *StoreExtension) getIconURL() string {
|
|||||||
return e.IconURLAlt
|
return e.IconURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
|
|
||||||
func (e *StoreExtension) getMinAppVersion() string {
|
func (e *StoreExtension) getMinAppVersion() string {
|
||||||
if e.MinAppVersion != "" {
|
if e.MinAppVersion != "" {
|
||||||
return e.MinAppVersion
|
return e.MinAppVersion
|
||||||
@@ -76,7 +71,6 @@ func (e *StoreExtension) getMinAppVersion() string {
|
|||||||
return e.MinAppVersionAlt
|
return e.MinAppVersionAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreRegistry represents the extension registry
|
|
||||||
type StoreRegistry struct {
|
type StoreRegistry struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
@@ -103,7 +97,6 @@ type StoreExtensionResponse struct {
|
|||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToResponse converts StoreExtension to normalized response
|
|
||||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||||
return StoreExtensionResponse{
|
return StoreExtensionResponse{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
@@ -122,7 +115,6 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionStore manages the extension store
|
|
||||||
type ExtensionStore struct {
|
type ExtensionStore struct {
|
||||||
registryURL string
|
registryURL string
|
||||||
cacheDir string
|
cacheDir string
|
||||||
@@ -143,7 +135,6 @@ const (
|
|||||||
cacheFileName = "store_cache.json"
|
cacheFileName = "store_cache.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitExtensionStore initializes the extension store
|
|
||||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
@@ -154,20 +145,17 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
|||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
}
|
}
|
||||||
// Try to load from disk cache
|
|
||||||
extensionStore.loadDiskCache()
|
extensionStore.loadDiskCache()
|
||||||
}
|
}
|
||||||
return extensionStore
|
return extensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtensionStore returns the singleton store instance
|
|
||||||
func GetExtensionStore() *ExtensionStore {
|
func GetExtensionStore() *ExtensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
return extensionStore
|
return extensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadDiskCache loads cached registry from disk
|
|
||||||
func (s *ExtensionStore) loadDiskCache() {
|
func (s *ExtensionStore) loadDiskCache() {
|
||||||
if s.cacheDir == "" {
|
if s.cacheDir == "" {
|
||||||
return
|
return
|
||||||
@@ -193,7 +181,6 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveDiskCache saves registry to disk cache
|
|
||||||
func (s *ExtensionStore) saveDiskCache() {
|
func (s *ExtensionStore) saveDiskCache() {
|
||||||
if s.cacheDir == "" || s.cache == nil {
|
if s.cacheDir == "" || s.cache == nil {
|
||||||
return
|
return
|
||||||
@@ -216,23 +203,24 @@ func (s *ExtensionStore) saveDiskCache() {
|
|||||||
os.WriteFile(cachePath, data, 0644)
|
os.WriteFile(cachePath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchRegistry fetches the extension registry from GitHub
|
|
||||||
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()
|
||||||
|
|
||||||
// Return cached if valid and not forcing refresh
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
resp, err := client.Get(s.registryURL)
|
resp, err := client.Get(s.registryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return cached data if available on network error
|
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||||
return s.cache, nil
|
return s.cache, nil
|
||||||
@@ -267,7 +255,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return ®istry, nil
|
return ®istry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtensionsWithStatus returns extensions with installation status
|
|
||||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.FetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -299,7 +286,6 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadExtension downloads an extension package to the specified path
|
|
||||||
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 {
|
||||||
@@ -318,6 +304,10 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Minute}
|
client := &http.Client{Timeout: 5 * time.Minute}
|
||||||
@@ -347,7 +337,20 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCategories returns all available categories
|
func requireHTTPSURL(rawURL string, context string) error {
|
||||||
|
if rawURL == "" {
|
||||||
|
return fmt.Errorf("%s URL is empty", context)
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetCategories() []string {
|
func (s *ExtensionStore) GetCategories() []string {
|
||||||
return []string{
|
return []string{
|
||||||
CategoryMetadata,
|
CategoryMetadata,
|
||||||
@@ -358,7 +361,6 @@ func (s *ExtensionStore) GetCategories() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchExtensions searches extensions by query
|
|
||||||
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()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -404,7 +406,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearCache clears the in-memory and disk cache
|
|
||||||
func (s *ExtensionStore) ClearCache() {
|
func (s *ExtensionStore) ClearCache() {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
// Test allowed domains
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -121,7 +120,6 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test blocked domains
|
|
||||||
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||||
t.Error("Expected blocked.com to be denied")
|
t.Error("Expected blocked.com to be denied")
|
||||||
}
|
}
|
||||||
@@ -139,7 +137,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
Permissions: ExtensionPermissions{
|
Permissions: ExtensionPermissions{
|
||||||
File: true, // Enable file permission for test
|
File: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
@@ -147,7 +145,6 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
// Test valid path within sandbox
|
|
||||||
validPath, err := runtime.validatePath("test.txt")
|
validPath, err := runtime.validatePath("test.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||||
@@ -156,13 +153,11 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
t.Error("Expected non-empty path")
|
t.Error("Expected non-empty path")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test path traversal attack
|
|
||||||
_, err = runtime.validatePath("../../../etc/passwd")
|
_, err = runtime.validatePath("../../../etc/passwd")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected path traversal to be blocked")
|
t.Error("Expected path traversal to be blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test nested path within sandbox (should be allowed)
|
|
||||||
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||||
@@ -171,26 +166,23 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
t.Error("Expected non-empty nested path")
|
t.Error("Expected non-empty nested path")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test absolute path should be blocked (security fix)
|
|
||||||
// Use platform-appropriate absolute path
|
|
||||||
var absPath string
|
var absPath string
|
||||||
if filepath.IsAbs("C:\\Windows\\System32") {
|
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||||
absPath = "C:\\Windows\\System32\\test.txt" // Windows
|
absPath = "C:\\Windows\\System32\\test.txt"
|
||||||
} else {
|
} else {
|
||||||
absPath = "/etc/passwd" // Unix
|
absPath = "/etc/passwd"
|
||||||
}
|
}
|
||||||
_, err = runtime.validatePath(absPath)
|
_, err = runtime.validatePath(absPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected absolute path to be blocked")
|
t.Error("Expected absolute path to be blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that extension without file permission is 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",
|
||||||
Permissions: ExtensionPermissions{
|
Permissions: ExtensionPermissions{
|
||||||
File: false, // No file permission
|
File: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
@@ -215,7 +207,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
|
|
||||||
// Test base64 encode/decode
|
|
||||||
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("base64Encode failed: %v", err)
|
t.Fatalf("base64Encode failed: %v", err)
|
||||||
@@ -232,7 +223,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
t.Errorf("Expected 'hello', got '%s'", result.String())
|
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test MD5
|
|
||||||
result, err = vm.RunString(`utils.md5("hello")`)
|
result, err = vm.RunString(`utils.md5("hello")`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("md5 failed: %v", err)
|
t.Fatalf("md5 failed: %v", err)
|
||||||
@@ -241,7 +231,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test JSON parse/stringify
|
|
||||||
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stringifyJSON failed: %v", err)
|
t.Fatalf("stringifyJSON failed: %v", err)
|
||||||
@@ -267,7 +256,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
|||||||
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
// Test that private IPs are blocked (SSRF protection)
|
|
||||||
privateIPs := []string{
|
privateIPs := []string{
|
||||||
"http://localhost/admin",
|
"http://localhost/admin",
|
||||||
"http://127.0.0.1/admin",
|
"http://127.0.0.1/admin",
|
||||||
@@ -285,7 +273,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that allowed public domain still works
|
|
||||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||||
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -296,7 +283,6 @@ func TestIsPrivateIP(t *testing.T) {
|
|||||||
host string
|
host string
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
// Private IPs should be blocked
|
|
||||||
{"localhost", true},
|
{"localhost", true},
|
||||||
{"127.0.0.1", true},
|
{"127.0.0.1", true},
|
||||||
{"127.0.0.2", true},
|
{"127.0.0.2", true},
|
||||||
@@ -306,18 +292,17 @@ func TestIsPrivateIP(t *testing.T) {
|
|||||||
{"172.31.255.255", true},
|
{"172.31.255.255", true},
|
||||||
{"192.168.0.1", true},
|
{"192.168.0.1", true},
|
||||||
{"192.168.255.255", true},
|
{"192.168.255.255", true},
|
||||||
{"169.254.169.254", true}, // AWS metadata
|
{"169.254.169.254", true},
|
||||||
{"router.local", true},
|
{"router.local", true},
|
||||||
{"mydevice.local", true},
|
{"mydevice.local", true},
|
||||||
|
|
||||||
// Public IPs should be allowed
|
|
||||||
{"8.8.8.8", false},
|
{"8.8.8.8", false},
|
||||||
{"1.1.1.1", false},
|
{"1.1.1.1", false},
|
||||||
{"api.example.com", false},
|
{"api.example.com", false},
|
||||||
{"google.com", false},
|
{"google.com", false},
|
||||||
{"172.15.0.1", false}, // Just outside 172.16-31 range
|
{"172.15.0.1", false},
|
||||||
{"172.32.0.1", false}, // Just outside 172.16-31 range
|
{"172.32.0.1", false},
|
||||||
{"192.167.0.1", false}, // Not 192.168.x.x
|
{"192.167.0.1", false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JSExecutionError represents an error during JS execution
|
|
||||||
type JSExecutionError struct {
|
type JSExecutionError struct {
|
||||||
Message string
|
Message string
|
||||||
IsTimeout bool
|
IsTimeout bool
|
||||||
@@ -20,8 +20,6 @@ func (e *JSExecutionError) Error() string {
|
|||||||
return e.Message
|
return e.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunWithTimeout executes JavaScript code with a timeout
|
|
||||||
// Returns the result value and any error (including timeout)
|
|
||||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = DefaultJSTimeout
|
timeout = DefaultJSTimeout
|
||||||
@@ -30,22 +28,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Channel to receive result
|
|
||||||
type result struct {
|
type result struct {
|
||||||
value goja.Value
|
value goja.Value
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
resultCh := make(chan result, 1)
|
resultCh := make(chan result, 1)
|
||||||
|
|
||||||
// Track if we've interrupted
|
|
||||||
var interrupted bool
|
var interrupted bool
|
||||||
var interruptMu sync.Mutex
|
var interruptMu sync.Mutex
|
||||||
|
|
||||||
// Run script in goroutine
|
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
// Check if this was our interrupt
|
|
||||||
interruptMu.Lock()
|
interruptMu.Lock()
|
||||||
wasInterrupted := interrupted
|
wasInterrupted := interrupted
|
||||||
interruptMu.Unlock()
|
interruptMu.Unlock()
|
||||||
@@ -56,6 +50,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()))
|
||||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,22 +60,18 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
resultCh <- result{val, err}
|
resultCh <- result{val, err}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Wait for result or timeout
|
|
||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
return res.value, res.err
|
return res.value, res.err
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Timeout - interrupt the VM
|
|
||||||
interruptMu.Lock()
|
interruptMu.Lock()
|
||||||
interrupted = true
|
interrupted = true
|
||||||
interruptMu.Unlock()
|
interruptMu.Unlock()
|
||||||
|
|
||||||
vm.Interrupt("execution timeout")
|
vm.Interrupt("execution timeout")
|
||||||
|
|
||||||
// Wait a bit for the goroutine to finish
|
|
||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
// If we got a result after interrupt, it might be the timeout error
|
|
||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
return nil, res.err
|
return nil, res.err
|
||||||
}
|
}
|
||||||
@@ -89,7 +80,6 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
|||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}
|
}
|
||||||
case <-time.After(1 * time.Second):
|
case <-time.After(1 * time.Second):
|
||||||
// Force return timeout error
|
|
||||||
return nil, &JSExecutionError{
|
return nil, &JSExecutionError{
|
||||||
Message: "execution timeout exceeded (force)",
|
Message: "execution timeout exceeded (force)",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
@@ -109,7 +99,6 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTimeoutError checks if an error is a timeout error
|
|
||||||
func IsTimeoutError(err error) bool {
|
func IsTimeoutError(err error) bool {
|
||||||
if jsErr, ok := err.(*JSExecutionError); ok {
|
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||||
return jsErr.IsTimeout
|
return jsErr.IsTimeout
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
module github.com/zarz/spotiflac_android/go_backend
|
module github.com/zarz/spotiflac_android/go_backend
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.24.5
|
toolchain go1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
|
github.com/refraction-networking/utls v1.8.2
|
||||||
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
|
||||||
|
golang.org/x/net v0.49.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
github.com/klauspost/compress v1.17.4 // indirect
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/tools v0.40.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,28 +1,50 @@
|
|||||||
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.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||||
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
||||||
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
||||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||||
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||||
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
|
||||||
// Uses modern Chrome format with build and patch numbers
|
|
||||||
// Windows 11 still reports as "Windows NT 10.0" for compatibility
|
|
||||||
func getRandomUserAgent() string {
|
func getRandomUserAgent() string {
|
||||||
// Chrome version 120-145 (modern range)
|
|
||||||
chromeVersion := rand.Intn(26) + 120
|
chromeVersion := rand.Intn(26) + 120
|
||||||
chromeBuild := rand.Intn(1500) + 6000
|
chromeBuild := rand.Intn(1500) + 6000
|
||||||
chromePatch := rand.Intn(200) + 100
|
chromePatch := rand.Intn(200) + 100
|
||||||
@@ -38,9 +34,9 @@ const (
|
|||||||
SongLinkTimeout = 30 * time.Second
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second
|
DefaultRetryDelay = 1 * time.Second
|
||||||
|
Second = time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -59,6 +55,27 @@ 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{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 30,
|
||||||
|
MaxIdleConnsPerHost: 5,
|
||||||
|
MaxConnsPerHost: 10,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
WriteBufferSize: 32 * 1024,
|
||||||
|
ReadBufferSize: 32 * 1024,
|
||||||
|
DisableCompression: true,
|
||||||
|
}
|
||||||
|
|
||||||
var sharedClient = &http.Client{
|
var sharedClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DefaultTimeout,
|
Timeout: DefaultTimeout,
|
||||||
@@ -76,6 +93,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
|
||||||
|
// Use this for API calls that should not be affected by download traffic.
|
||||||
|
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Transport: metadataTransport,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetSharedClient() *http.Client {
|
func GetSharedClient() *http.Client {
|
||||||
return sharedClient
|
return sharedClient
|
||||||
}
|
}
|
||||||
@@ -84,9 +110,9 @@ func GetDownloadClient() *http.Client {
|
|||||||
return downloadClient
|
return downloadClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseIdleConnections closes idle connections in the shared transport
|
|
||||||
func CloseIdleConnections() {
|
func CloseIdleConnections() {
|
||||||
sharedTransport.CloseIdleConnections()
|
sharedTransport.CloseIdleConnections()
|
||||||
|
metadataTransport.CloseIdleConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also checks for ISP blocking on errors
|
// Also checks for ISP blocking on errors
|
||||||
@@ -116,16 +142,12 @@ func DefaultRetryConfig() RetryConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
|
||||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
|
||||||
// Also detects and logs ISP blocking
|
|
||||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
delay := config.InitialDelay
|
delay := config.InitialDelay
|
||||||
requestURL := req.URL.String()
|
requestURL := req.URL.String()
|
||||||
|
|
||||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||||
// Clone request for retry (body needs to be re-readable)
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
@@ -133,9 +155,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
// Check for ISP blocking on network errors
|
|
||||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||||
// Don't retry if ISP blocking is detected - it won't help
|
|
||||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,12 +168,10 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle rate limiting (429)
|
|
||||||
if resp.StatusCode == 429 {
|
if resp.StatusCode == 429 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
retryAfter := getRetryAfterDuration(resp)
|
retryAfter := getRetryAfterDuration(resp)
|
||||||
@@ -193,7 +211,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server errors (5xx) - retry
|
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||||
@@ -205,7 +222,6 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client errors (4xx except 429) - don't retry
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,12 +240,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
return 60 * time.Second // Default wait time
|
return 60 * time.Second // Default wait time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as seconds
|
|
||||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
return time.Duration(seconds) * time.Second
|
return time.Duration(seconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as HTTP date
|
|
||||||
if t, err := http.ParseTime(retryAfter); err == nil {
|
if t, err := http.ParseTime(retryAfter); err == nil {
|
||||||
duration := time.Until(t)
|
duration := time.Until(t)
|
||||||
if duration > 0 {
|
if duration > 0 {
|
||||||
@@ -240,8 +254,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
return 60 * time.Second // Default
|
return 60 * time.Second // Default
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadResponseBody reads and returns the response body
|
|
||||||
// Returns error if body is empty
|
|
||||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
return nil, fmt.Errorf("response is nil")
|
return nil, fmt.Errorf("response is nil")
|
||||||
@@ -271,14 +283,12 @@ func ValidateResponse(resp *http.Response) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildErrorMessage creates a detailed error message for API failures
|
|
||||||
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
|
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
|
||||||
msg := fmt.Sprintf("API %s failed", apiURL)
|
msg := fmt.Sprintf("API %s failed", apiURL)
|
||||||
if statusCode > 0 {
|
if statusCode > 0 {
|
||||||
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
|
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
|
||||||
}
|
}
|
||||||
if responsePreview != "" {
|
if responsePreview != "" {
|
||||||
// Truncate preview if too long
|
|
||||||
if len(responsePreview) > 100 {
|
if len(responsePreview) > 100 {
|
||||||
responsePreview = responsePreview[:100] + "..."
|
responsePreview = responsePreview[:100] + "..."
|
||||||
}
|
}
|
||||||
@@ -297,18 +307,14 @@ func (e *ISPBlockingError) Error() string {
|
|||||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
|
||||||
// Returns the ISPBlockingError if detected, nil otherwise
|
|
||||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract domain from URL
|
|
||||||
domain := extractDomain(requestURL)
|
domain := extractDomain(requestURL)
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
// Check for DNS resolution failure (common ISP blocking method)
|
|
||||||
var dnsErr *net.DNSError
|
var dnsErr *net.DNSError
|
||||||
if errors.As(err, &dnsErr) {
|
if errors.As(err, &dnsErr) {
|
||||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||||
@@ -320,11 +326,9 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for connection refused (ISP firewall blocking)
|
|
||||||
var opErr *net.OpError
|
var opErr *net.OpError
|
||||||
if errors.As(err, &opErr) {
|
if errors.As(err, &opErr) {
|
||||||
if opErr.Op == "dial" {
|
if opErr.Op == "dial" {
|
||||||
// Check for specific syscall errors
|
|
||||||
var syscallErr syscall.Errno
|
var syscallErr syscall.Errno
|
||||||
if errors.As(opErr.Err, &syscallErr) {
|
if errors.As(opErr.Err, &syscallErr) {
|
||||||
switch syscallErr {
|
switch syscallErr {
|
||||||
@@ -363,7 +367,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
|
||||||
var tlsErr *tls.RecordHeaderError
|
var tlsErr *tls.RecordHeaderError
|
||||||
if errors.As(err, &tlsErr) {
|
if errors.As(err, &tlsErr) {
|
||||||
return &ISPBlockingError{
|
return &ISPBlockingError{
|
||||||
@@ -424,7 +427,6 @@ func extractDomain(rawURL string) string {
|
|||||||
|
|
||||||
parsed, err := url.Parse(rawURL)
|
parsed, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try to extract domain manually
|
|
||||||
rawURL = strings.TrimPrefix(rawURL, "https://")
|
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||||
rawURL = strings.TrimPrefix(rawURL, "http://")
|
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||||
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//go:build ios
|
||||||
|
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
|
||||||
|
// Fall back to standard HTTP client
|
||||||
|
|
||||||
|
// GetCloudflareBypassClient returns the standard HTTP client on iOS
|
||||||
|
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
|
||||||
|
func GetCloudflareBypassClient() *http.Client {
|
||||||
|
return sharedClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoRequestWithCloudflareBypass on iOS just uses the standard client
|
||||||
|
// uTLS Chrome fingerprint bypass is not available on iOS
|
||||||
|
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
resp, err := sharedClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
//go:build !ios
|
||||||
|
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
utls "github.com/refraction-networking/utls"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
|
||||||
|
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
|
||||||
|
type utlsTransport struct {
|
||||||
|
dialer *net.Dialer
|
||||||
|
mu sync.Mutex
|
||||||
|
h2Transports map[string]*http2.Transport
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUTLSTransport() *utlsTransport {
|
||||||
|
return &utlsTransport{
|
||||||
|
dialer: &net.Dialer{
|
||||||
|
Timeout: 30 * Second,
|
||||||
|
KeepAlive: 30 * Second,
|
||||||
|
},
|
||||||
|
h2Transports: make(map[string]*http2.Transport),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Scheme != "https" {
|
||||||
|
return sharedTransport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := req.URL.Hostname()
|
||||||
|
port := t.getPort(req.URL)
|
||||||
|
addr := net.JoinHostPort(host, port)
|
||||||
|
|
||||||
|
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConn := utls.UClient(conn, &utls.Config{
|
||||||
|
ServerName: host,
|
||||||
|
NextProtos: []string{"h2", "http/1.1"},
|
||||||
|
}, utls.HelloChrome_Auto)
|
||||||
|
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||||
|
|
||||||
|
if negotiatedProto == "h2" {
|
||||||
|
h2Transport := &http2.Transport{
|
||||||
|
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||||||
|
return tlsConn, nil
|
||||||
|
},
|
||||||
|
AllowHTTP: false,
|
||||||
|
DisableCompression: false,
|
||||||
|
}
|
||||||
|
return h2Transport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return tlsConn, nil
|
||||||
|
},
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return transport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsTransport) getPort(u *url.URL) string {
|
||||||
|
if u.Port() != "" {
|
||||||
|
return u.Port()
|
||||||
|
}
|
||||||
|
if u.Scheme == "https" {
|
||||||
|
return "443"
|
||||||
|
}
|
||||||
|
return "80"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloudflare bypass client using uTLS Chrome fingerprint
|
||||||
|
var cloudflareBypassTransport = newUTLSTransport()
|
||||||
|
|
||||||
|
var cloudflareBypassClient = &http.Client{
|
||||||
|
Transport: cloudflareBypassTransport,
|
||||||
|
Timeout: DefaultTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
|
||||||
|
// Use this when requests are blocked by Cloudflare (common when using VPN)
|
||||||
|
func GetCloudflareBypassClient() *http.Client {
|
||||||
|
return cloudflareBypassClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoRequestWithCloudflareBypass attempts request with standard client first,
|
||||||
|
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
|
||||||
|
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
|
||||||
|
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
// Try with standard client first
|
||||||
|
resp, err := sharedClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
// Check for Cloudflare challenge page (403 with specific markers)
|
||||||
|
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if readErr == nil {
|
||||||
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
cloudflareMarkers := []string{
|
||||||
|
"cloudflare", "cf-ray", "checking your browser",
|
||||||
|
"please wait", "ddos protection", "ray id",
|
||||||
|
"enable javascript", "challenge-platform",
|
||||||
|
}
|
||||||
|
|
||||||
|
isCloudflare := false
|
||||||
|
for _, marker := range cloudflareMarkers {
|
||||||
|
if strings.Contains(bodyStr, marker) {
|
||||||
|
isCloudflare = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCloudflare {
|
||||||
|
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||||
|
|
||||||
|
// Clone request for retry
|
||||||
|
reqCopy := req.Clone(req.Context())
|
||||||
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
// Retry with uTLS Chrome fingerprint
|
||||||
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not Cloudflare, return original response (recreate body)
|
||||||
|
return &http.Response{
|
||||||
|
Status: resp.Status,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Header: resp.Header,
|
||||||
|
Body: io.NopCloser(strings.NewReader(string(body))),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if error might be TLS-related (Cloudflare blocking)
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||||
|
strings.Contains(errStr, "handshake") ||
|
||||||
|
strings.Contains(errStr, "certificate") ||
|
||||||
|
strings.Contains(errStr, "connection reset")
|
||||||
|
|
||||||
|
if tlsRelated {
|
||||||
|
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||||
|
|
||||||
|
// Clone request for retry
|
||||||
|
reqCopy := req.Clone(req.Context())
|
||||||
|
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
// Retry with uTLS Chrome fingerprint
|
||||||
|
return cloudflareBypassClient.Do(reqCopy)
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IDHSClient is a client for I Don't Have Spotify API
|
||||||
|
// Used as fallback when SongLink fails or is rate limited
|
||||||
|
type IDHSClient struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalIDHSClient *IDHSClient
|
||||||
|
idhsClientOnce sync.Once
|
||||||
|
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
// IDHSSearchRequest represents the request body for IDHS API
|
||||||
|
type IDHSSearchRequest struct {
|
||||||
|
Link string `json:"link"`
|
||||||
|
Adapters []string `json:"adapters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDHSSearchResponse represents the response from IDHS API
|
||||||
|
type IDHSSearchResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // song, album, artist, podcast, show
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Audio string `json:"audio,omitempty"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
UniversalLink string `json:"universalLink"`
|
||||||
|
Links []IDHSLink `json:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDHSLink represents a link to a streaming platform
|
||||||
|
type IDHSLink struct {
|
||||||
|
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
||||||
|
URL string `json:"url"`
|
||||||
|
IsVerified bool `json:"isVerified,omitempty"`
|
||||||
|
NotAvailable bool `json:"notAvailable,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIDHSClient creates a new IDHS client
|
||||||
|
func NewIDHSClient() *IDHSClient {
|
||||||
|
idhsClientOnce.Do(func() {
|
||||||
|
globalIDHSClient = &IDHSClient{
|
||||||
|
client: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalIDHSClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search converts a music link to links on other platforms
|
||||||
|
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
||||||
|
idhsRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
reqBody := IDHSSearchRequest{
|
||||||
|
Link: link,
|
||||||
|
Adapters: adapters,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("invalid link or missing parameters")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("IDHS rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 500 {
|
||||||
|
return nil, fmt.Errorf("IDHS processing failed")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result IDHSSearchResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
|
||||||
|
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
|
|
||||||
|
// Request only the platforms we need
|
||||||
|
adapters := []string{"tidal", "deezer"}
|
||||||
|
|
||||||
|
result, err := c.Search(spotifyURL, adapters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
SpotifyID: spotifyTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, link := range result.Links {
|
||||||
|
if link.NotAvailable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(link.Type) {
|
||||||
|
case "tidal":
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = link.URL
|
||||||
|
case "deezer":
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = link.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(link.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v",
|
||||||
|
spotifyTrackID, availability.Tidal, availability.Deezer)
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailabilityFromDeezer checks track availability using IDHS
|
||||||
|
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
|
// Request only the platforms we need
|
||||||
|
adapters := []string{"spotify", "tidal"}
|
||||||
|
|
||||||
|
result, err := c.Search(deezerURL, adapters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
Deezer: true,
|
||||||
|
DeezerID: deezerTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, link := range result.Links {
|
||||||
|
if link.NotAvailable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(link.Type) {
|
||||||
|
case "spotify":
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(link.URL)
|
||||||
|
case "tidal":
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = link.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v",
|
||||||
|
deezerTrackID, availability.SpotifyID, availability.Tidal)
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibraryScanResult represents metadata from a scanned audio file
|
||||||
|
type LibraryScanResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TrackName string `json:"trackName"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
CoverPath string `json:"coverPath,omitempty"`
|
||||||
|
ScannedAt string `json:"scannedAt"`
|
||||||
|
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
TrackNumber int `json:"trackNumber,omitempty"`
|
||||||
|
DiscNumber int `json:"discNumber,omitempty"`
|
||||||
|
Duration int `json:"duration,omitempty"`
|
||||||
|
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||||
|
BitDepth int `json:"bitDepth,omitempty"`
|
||||||
|
SampleRate int `json:"sampleRate,omitempty"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryScanProgress struct {
|
||||||
|
TotalFiles int `json:"total_files"`
|
||||||
|
ScannedFiles int `json:"scanned_files"`
|
||||||
|
CurrentFile string `json:"current_file"`
|
||||||
|
ErrorCount int `json:"error_count"`
|
||||||
|
ProgressPct float64 `json:"progress_pct"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementalScanResult contains results of an incremental library scan
|
||||||
|
type IncrementalScanResult struct {
|
||||||
|
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
||||||
|
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
||||||
|
SkippedCount int `json:"skippedCount"` // Files that were unchanged
|
||||||
|
TotalFiles int `json:"totalFiles"` // Total files in folder
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
libraryScanProgress LibraryScanProgress
|
||||||
|
libraryScanProgressMu sync.RWMutex
|
||||||
|
libraryScanCancel chan struct{}
|
||||||
|
libraryScanCancelMu sync.Mutex
|
||||||
|
libraryCoverCacheDir string
|
||||||
|
libraryCoverCacheMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedAudioFormats = map[string]bool{
|
||||||
|
".flac": true,
|
||||||
|
".m4a": true,
|
||||||
|
".mp3": true,
|
||||||
|
".opus": true,
|
||||||
|
".ogg": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||||
|
libraryCoverCacheMu.Lock()
|
||||||
|
libraryCoverCacheDir = cacheDir
|
||||||
|
libraryCoverCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanLibraryFolder(folderPath string) (string, error) {
|
||||||
|
if folderPath == "" {
|
||||||
|
return "[]", fmt.Errorf("folder path is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", fmt.Errorf("folder not found: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return "[]", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress = LibraryScanProgress{}
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
libraryScanCancelMu.Lock()
|
||||||
|
if libraryScanCancel != nil {
|
||||||
|
close(libraryScanCancel)
|
||||||
|
}
|
||||||
|
libraryScanCancel = make(chan struct{})
|
||||||
|
cancelCh := libraryScanCancel
|
||||||
|
libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
|
var audioFiles []string
|
||||||
|
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsDir() {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if supportedAudioFormats[ext] {
|
||||||
|
audioFiles = append(audioFiles, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFiles := len(audioFiles)
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
if totalFiles == 0 {
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
return "[]", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
|
||||||
|
|
||||||
|
results := make([]LibraryScanResult, 0, totalFiles)
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
for i, filePath := range audioFiles {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return "[]", fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ScannedFiles = i + 1
|
||||||
|
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
||||||
|
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
result, err := scanAudioFile(filePath, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, *result)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ErrorCount = errorCount
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount)
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
|
result := &LibraryScanResult{
|
||||||
|
ID: generateLibraryID(filePath),
|
||||||
|
FilePath: filePath,
|
||||||
|
ScannedAt: scanTime,
|
||||||
|
Format: strings.TrimPrefix(ext, "."),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file modification time
|
||||||
|
if info, err := os.Stat(filePath); err == nil {
|
||||||
|
result.FileModTime = info.ModTime().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryCoverCacheMu.RLock()
|
||||||
|
coverCacheDir := libraryCoverCacheDir
|
||||||
|
libraryCoverCacheMu.RUnlock()
|
||||||
|
if coverCacheDir != "" && ext != ".m4a" {
|
||||||
|
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
||||||
|
if err == nil && coverPath != "" {
|
||||||
|
result.CoverPath = coverPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".flac":
|
||||||
|
return scanFLACFile(filePath, result)
|
||||||
|
case ".m4a":
|
||||||
|
return scanM4AFile(filePath, result)
|
||||||
|
case ".mp3":
|
||||||
|
return scanMP3File(filePath, result)
|
||||||
|
case ".opus", ".ogg":
|
||||||
|
return scanOggFile(filePath, result)
|
||||||
|
default:
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadMetadata(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
|
||||||
|
quality, err := GetAudioQuality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
|
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.TrackName == "" {
|
||||||
|
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
}
|
||||||
|
if result.ArtistName == "" {
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
if result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
quality, err := GetM4AQuality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadID3Tags(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
if metadata.Date != "" {
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
} else {
|
||||||
|
result.ReleaseDate = metadata.Year
|
||||||
|
}
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
|
||||||
|
quality, err := GetMP3Quality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.Duration = quality.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.TrackName == "" {
|
||||||
|
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
}
|
||||||
|
if result.ArtistName == "" {
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
if result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
metadata, err := ReadOggVorbisComments(filePath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||||
|
return scanFromFilename(filePath, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
|
||||||
|
quality, err := GetOggQuality(filePath)
|
||||||
|
if err == nil {
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.Duration = quality.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.TrackName == "" {
|
||||||
|
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
}
|
||||||
|
if result.ArtistName == "" {
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
if result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
|
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
|
||||||
|
parts := strings.SplitN(filename, " - ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
|
||||||
|
result.TrackName = parts[1]
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
} else {
|
||||||
|
result.ArtistName = parts[0]
|
||||||
|
result.TrackName = parts[1]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(filename) > 3 && isNumeric(filename[:2]) {
|
||||||
|
title := strings.TrimLeft(filename[2:], " .-")
|
||||||
|
result.TrackName = title
|
||||||
|
} else {
|
||||||
|
result.TrackName = filename
|
||||||
|
}
|
||||||
|
result.ArtistName = "Unknown Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(filePath)
|
||||||
|
result.AlbumName = filepath.Base(dir)
|
||||||
|
if result.AlbumName == "." || result.AlbumName == "" {
|
||||||
|
result.AlbumName = "Unknown Album"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNumeric(s string) bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateLibraryID(filePath string) string {
|
||||||
|
return fmt.Sprintf("lib_%x", hashString(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashString(s string) uint32 {
|
||||||
|
var hash uint32 = 5381
|
||||||
|
for _, c := range s {
|
||||||
|
hash = ((hash << 5) + hash) + uint32(c)
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLibraryScanProgress() string {
|
||||||
|
libraryScanProgressMu.RLock()
|
||||||
|
defer libraryScanProgressMu.RUnlock()
|
||||||
|
|
||||||
|
jsonBytes, _ := json.Marshal(libraryScanProgress)
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CancelLibraryScan() {
|
||||||
|
libraryScanCancelMu.Lock()
|
||||||
|
defer libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
|
if libraryScanCancel != nil {
|
||||||
|
close(libraryScanCancel)
|
||||||
|
libraryScanCancel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadata(filePath string) (string, error) {
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
result, err := scanAudioFile(filePath, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||||
|
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||||
|
// Only files that are new or have changed modification time will be scanned
|
||||||
|
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||||
|
if folderPath == "" {
|
||||||
|
return "{}", fmt.Errorf("folder path is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", fmt.Errorf("folder not found: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing files map
|
||||||
|
existingFiles := make(map[string]int64)
|
||||||
|
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||||
|
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||||
|
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||||
|
|
||||||
|
// Reset progress
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress = LibraryScanProgress{}
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
// Setup cancellation
|
||||||
|
libraryScanCancelMu.Lock()
|
||||||
|
if libraryScanCancel != nil {
|
||||||
|
close(libraryScanCancel)
|
||||||
|
}
|
||||||
|
libraryScanCancel = make(chan struct{})
|
||||||
|
cancelCh := libraryScanCancel
|
||||||
|
libraryScanCancelMu.Unlock()
|
||||||
|
|
||||||
|
// Collect all audio files with their mod times
|
||||||
|
type fileInfo struct {
|
||||||
|
path string
|
||||||
|
modTime int64
|
||||||
|
}
|
||||||
|
var currentFiles []fileInfo
|
||||||
|
currentPathSet := make(map[string]bool)
|
||||||
|
|
||||||
|
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.IsDir() {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if supportedAudioFormats[ext] {
|
||||||
|
currentFiles = append(currentFiles, fileInfo{
|
||||||
|
path: path,
|
||||||
|
modTime: info.ModTime().UnixMilli(),
|
||||||
|
})
|
||||||
|
currentPathSet[path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "{}", err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFiles := len(currentFiles)
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
// Find files to scan (new or modified)
|
||||||
|
var filesToScan []fileInfo
|
||||||
|
skippedCount := 0
|
||||||
|
|
||||||
|
for _, f := range currentFiles {
|
||||||
|
existingModTime, exists := existingFiles[f.path]
|
||||||
|
if !exists {
|
||||||
|
// New file
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
} else if f.modTime != existingModTime {
|
||||||
|
// Modified file
|
||||||
|
filesToScan = append(filesToScan, f)
|
||||||
|
} else {
|
||||||
|
// Unchanged file - skip
|
||||||
|
skippedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find deleted files
|
||||||
|
var deletedPaths []string
|
||||||
|
for existingPath := range existingFiles {
|
||||||
|
if !currentPathSet[existingPath] {
|
||||||
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
|
||||||
|
len(filesToScan), skippedCount, len(deletedPaths))
|
||||||
|
|
||||||
|
if len(filesToScan) == 0 {
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ScannedFiles = totalFiles
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgress.ProgressPct = 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
result := IncrementalScanResult{
|
||||||
|
Scanned: []LibraryScanResult{},
|
||||||
|
DeletedPaths: deletedPaths,
|
||||||
|
SkippedCount: skippedCount,
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.Marshal(result)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the files that need scanning
|
||||||
|
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||||
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
for i, f := range filesToScan {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return "{}", fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ScannedFiles = skippedCount + i + 1
|
||||||
|
libraryScanProgress.CurrentFile = filepath.Base(f.path)
|
||||||
|
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
result, err := scanAudioFile(f.path, scanTime)
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, *result)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ErrorCount = errorCount
|
||||||
|
libraryScanProgress.IsComplete = true
|
||||||
|
libraryScanProgress.ScannedFiles = totalFiles
|
||||||
|
libraryScanProgress.ProgressPct = 100
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
|
||||||
|
len(results), skippedCount, len(deletedPaths), errorCount)
|
||||||
|
|
||||||
|
scanResult := IncrementalScanResult{
|
||||||
|
Scanned: results,
|
||||||
|
DeletedPaths: deletedPaths,
|
||||||
|
SkippedCount: skippedCount,
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(scanResult)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,30 +23,55 @@ type LogBuffer struct {
|
|||||||
loggingEnabled bool
|
loggingEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultLogBufferSize = 500
|
||||||
|
maxLogMessageLength = 500
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalLogBuffer *LogBuffer
|
globalLogBuffer *LogBuffer
|
||||||
logBufferOnce sync.Once
|
logBufferOnce sync.Once
|
||||||
|
|
||||||
|
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||||
|
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
|
||||||
|
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
|
||||||
|
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetLogBuffer returns the singleton log buffer instance
|
func sanitizeSensitiveLogText(message string) string {
|
||||||
|
redacted := message
|
||||||
|
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
|
||||||
|
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
|
||||||
|
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||||
|
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
|
|
||||||
func GetLogBuffer() *LogBuffer {
|
func GetLogBuffer() *LogBuffer {
|
||||||
logBufferOnce.Do(func() {
|
logBufferOnce.Do(func() {
|
||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
entries: make([]LogEntry, 0, 1000),
|
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||||
maxSize: 1000,
|
maxSize: defaultLogBufferSize,
|
||||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalLogBuffer
|
return globalLogBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateLogMessage(message string) string {
|
||||||
|
runes := []rune(message)
|
||||||
|
if len(runes) <= maxLogMessageLength {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
lb.loggingEnabled = enabled
|
lb.loggingEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsLoggingEnabled returns whether logging is enabled
|
|
||||||
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
@@ -60,6 +86,9 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
Level: level,
|
Level: level,
|
||||||
@@ -75,7 +104,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
fmt.Printf("[%s] %s\n", tag, message)
|
fmt.Printf("[%s] %s\n", tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll returns all log entries as JSON
|
|
||||||
func (lb *LogBuffer) GetAll() string {
|
func (lb *LogBuffer) GetAll() string {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
@@ -99,21 +127,18 @@ func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
|||||||
return entries, len(lb.entries)
|
return entries, len(lb.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear clears all log entries
|
|
||||||
func (lb *LogBuffer) Clear() {
|
func (lb *LogBuffer) Clear() {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
lb.entries = lb.entries[:0]
|
lb.entries = lb.entries[:0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the number of log entries
|
|
||||||
func (lb *LogBuffer) Count() int {
|
func (lb *LogBuffer) Count() int {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
return len(lb.entries)
|
return len(lb.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for logging with different levels
|
|
||||||
func LogDebug(tag, format string, args ...interface{}) {
|
func LogDebug(tag, format string, args ...interface{}) {
|
||||||
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
@@ -150,11 +175,11 @@ func GoLog(format string, args ...interface{}) {
|
|||||||
|
|
||||||
// Determine level from message content
|
// Determine level from message content
|
||||||
msgLower := strings.ToLower(message)
|
msgLower := strings.ToLower(message)
|
||||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
||||||
level = "ERROR"
|
level = "ERROR"
|
||||||
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||||
level = "WARN"
|
level = "WARN"
|
||||||
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
} else if strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||||
level = "INFO"
|
level = "INFO"
|
||||||
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||||
level = "DEBUG"
|
level = "DEBUG"
|
||||||
@@ -163,15 +188,10 @@ func GoLog(format string, args ...interface{}) {
|
|||||||
GetLogBuffer().Add(level, tag, message)
|
GetLogBuffer().Add(level, tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported functions for Flutter
|
|
||||||
|
|
||||||
// GetLogs returns all logs as JSON array
|
|
||||||
func GetLogs() string {
|
func GetLogs() string {
|
||||||
return GetLogBuffer().GetAll()
|
return GetLogBuffer().GetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogsSince returns logs since the given index
|
|
||||||
// Returns JSON: {"logs": [...], "next_index": N}
|
|
||||||
func GetLogsSince(index int) string {
|
func GetLogsSince(index int) string {
|
||||||
entries, nextIndex := GetLogBuffer().getSince(index)
|
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||||
logsJson, _ := json.Marshal(entries)
|
logsJson, _ := json.Marshal(entries)
|
||||||
@@ -179,17 +199,14 @@ func GetLogsSince(index int) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearLogs clears all logs
|
|
||||||
func ClearLogs() {
|
func ClearLogs() {
|
||||||
GetLogBuffer().Clear()
|
GetLogBuffer().Clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogCount returns the number of log entries
|
|
||||||
func GetLogCount() int {
|
func GetLogCount() int {
|
||||||
return GetLogBuffer().Count()
|
return GetLogBuffer().Count()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLoggingEnabled enables or disables logging from Flutter
|
|
||||||
func SetLoggingEnabled(enabled bool) {
|
func SetLoggingEnabled(enabled bool) {
|
||||||
GetLogBuffer().SetLoggingEnabled(enabled)
|
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,9 +238,9 @@ func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool
|
|||||||
return diff <= durationToleranceSec
|
return diff <= durationToleranceSec
|
||||||
}
|
}
|
||||||
|
|
||||||
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
// Check cache first
|
primaryArtist := normalizeArtistName(artistName)
|
||||||
|
|
||||||
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||||
cachedCopy := *cached
|
cachedCopy := *cached
|
||||||
@@ -251,39 +251,48 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
var lyrics *LyricsResponse
|
var lyrics *LyricsResponse
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Try exact match first
|
isValidResult := func(l *LyricsResponse) bool {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
}
|
||||||
|
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try with simplified track name
|
if primaryArtist != artistName {
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
|
lyrics.Source = "LRCLIB"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB (simplified)"
|
lyrics.Source = "LRCLIB (simplified)"
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search with duration matching
|
query := primaryArtist + " " + trackName
|
||||||
query := artistName + " " + trackName
|
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB Search"
|
lyrics.Source = "LRCLIB Search"
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search with simplified name and duration matching
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = artistName + " " + simplifiedTrack
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB Search (simplified)"
|
lyrics.Source = "LRCLIB Search (simplified)"
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
@@ -375,32 +384,6 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use convertToLRCWithMetadata for full LRC with headers
|
|
||||||
// Kept for potential future use
|
|
||||||
// func convertToLRC(lyrics *LyricsResponse) string {
|
|
||||||
// if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
// return ""
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// var builder strings.Builder
|
|
||||||
//
|
|
||||||
// if lyrics.SyncType == "LINE_SYNCED" {
|
|
||||||
// for _, line := range lyrics.Lines {
|
|
||||||
// timestamp := msToLRCTimestamp(line.StartTimeMs)
|
|
||||||
// builder.WriteString(timestamp)
|
|
||||||
// builder.WriteString(line.Words)
|
|
||||||
// builder.WriteString("\n")
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// for _, line := range lyrics.Lines {
|
|
||||||
// builder.WriteString(line.Words)
|
|
||||||
// builder.WriteString("\n")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return builder.String()
|
|
||||||
// }
|
|
||||||
|
|
||||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -462,6 +445,20 @@ func simplifyTrackName(name string) string {
|
|||||||
return strings.TrimSpace(result)
|
return strings.TrimSpace(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeArtistName(name string) string {
|
||||||
|
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
|
||||||
|
|
||||||
|
result := name
|
||||||
|
for _, sep := range separators {
|
||||||
|
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
|
||||||
|
result = result[:idx]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||||
if lrcContent == "" {
|
if lrcContent == "" {
|
||||||
return "", fmt.Errorf("empty LRC content")
|
return "", fmt.Errorf("empty LRC content")
|
||||||
|
|||||||
@@ -4,16 +4,97 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
stdimage "image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture/v2"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis/v2"
|
||||||
"github.com/go-flac/go-flac"
|
"github.com/go-flac/go-flac/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func detectCoverMIME(coverPath string, coverData []byte) string {
|
||||||
|
// Prefer magic-byte detection over file extension.
|
||||||
|
// Some providers return non-JPEG data behind .jpg URLs.
|
||||||
|
if len(coverData) >= 8 &&
|
||||||
|
coverData[0] == 0x89 &&
|
||||||
|
coverData[1] == 0x50 &&
|
||||||
|
coverData[2] == 0x4E &&
|
||||||
|
coverData[3] == 0x47 &&
|
||||||
|
coverData[4] == 0x0D &&
|
||||||
|
coverData[5] == 0x0A &&
|
||||||
|
coverData[6] == 0x1A &&
|
||||||
|
coverData[7] == 0x0A {
|
||||||
|
return "image/png"
|
||||||
|
}
|
||||||
|
if len(coverData) >= 3 &&
|
||||||
|
coverData[0] == 0xFF &&
|
||||||
|
coverData[1] == 0xD8 &&
|
||||||
|
coverData[2] == 0xFF {
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
if len(coverData) >= 6 {
|
||||||
|
header := string(coverData[:6])
|
||||||
|
if header == "GIF87a" || header == "GIF89a" {
|
||||||
|
return "image/gif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(coverData) >= 12 &&
|
||||||
|
string(coverData[:4]) == "RIFF" &&
|
||||||
|
string(coverData[8:12]) == "WEBP" {
|
||||||
|
return "image/webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(filepath.Ext(strings.TrimSpace(coverPath))) {
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
||||||
|
if len(coverData) == 0 {
|
||||||
|
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
||||||
|
}
|
||||||
|
|
||||||
|
mime := detectCoverMIME(coverPath, coverData)
|
||||||
|
picture := &flacpicture.MetadataBlockPicture{
|
||||||
|
PictureType: flacpicture.PictureTypeFrontCover,
|
||||||
|
MIME: mime,
|
||||||
|
Description: "Front Cover",
|
||||||
|
ImageData: coverData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width/height/depth are optional in practice; keep zero when decode fails.
|
||||||
|
if cfg, format, err := stdimage.DecodeConfig(bytes.NewReader(coverData)); err == nil {
|
||||||
|
picture.Width = uint32(cfg.Width)
|
||||||
|
picture.Height = uint32(cfg.Height)
|
||||||
|
switch format {
|
||||||
|
case "png":
|
||||||
|
picture.ColorDepth = 32
|
||||||
|
case "jpeg":
|
||||||
|
picture.ColorDepth = 24
|
||||||
|
default:
|
||||||
|
picture.ColorDepth = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return picture.Marshal(), nil
|
||||||
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
@@ -29,6 +110,8 @@ type Metadata struct {
|
|||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
Copyright string
|
Copyright string
|
||||||
|
Composer string
|
||||||
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||||
@@ -98,6 +181,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if metadata.Composer != "" {
|
||||||
|
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Comment != "" {
|
||||||
|
setComment(cmt, "COMMENT", metadata.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -117,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||||
flacpicture.PictureTypeFrontCover,
|
|
||||||
"Front Cover",
|
|
||||||
coverData,
|
|
||||||
"image/jpeg",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
return fmt.Errorf("failed to create picture block: %w", err)
|
||||||
} else {
|
|
||||||
picBlock := picture.Marshal()
|
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
||||||
@@ -206,6 +290,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if metadata.Composer != "" {
|
||||||
|
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Comment != "" {
|
||||||
|
setComment(cmt, "COMMENT", metadata.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -220,25 +312,17 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picBlock, err := buildPictureBlock("", coverData)
|
||||||
flacpicture.PictureTypeFrontCover,
|
|
||||||
"Front Cover",
|
|
||||||
coverData,
|
|
||||||
"image/jpeg",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
return fmt.Errorf("failed to create picture block: %w", err)
|
||||||
} else {
|
|
||||||
picBlock := picture.Marshal()
|
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadMetadata reads metadata from a FLAC file
|
|
||||||
func ReadMetadata(filePath string) (*Metadata, error) {
|
func ReadMetadata(filePath string) (*Metadata, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -293,6 +377,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
metadata.Date = getComment(cmt, "YEAR")
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metadata.Genre = getComment(cmt, "GENRE")
|
||||||
|
metadata.Label = getComment(cmt, "ORGANIZATION")
|
||||||
|
metadata.Copyright = getComment(cmt, "COPYRIGHT")
|
||||||
|
metadata.Composer = getComment(cmt, "COMPOSER")
|
||||||
|
metadata.Comment = getComment(cmt, "COMMENT")
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,6 +426,39 @@ func fileExists(path string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExtractCoverArt(filePath string) ([]byte, error) {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.Picture {
|
||||||
|
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
|
||||||
|
return pic.ImageData, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.Picture {
|
||||||
|
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(pic.ImageData) > 0 {
|
||||||
|
return pic.ImageData, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no cover art found in file")
|
||||||
|
}
|
||||||
|
|
||||||
func EmbedLyrics(filePath string, lyrics string) error {
|
func EmbedLyrics(filePath string, lyrics string) error {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -418,35 +541,92 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
|||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
lower := strings.ToLower(filePath)
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".flac") {
|
||||||
|
return extractLyricsFromFlac(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".mp3") {
|
||||||
|
meta, err := ReadID3Tags(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||||
|
meta, err := ReadOggVorbisComments(filePath)
|
||||||
|
if err != nil || meta == nil {
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("unsupported file format for lyrics extraction")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, meta := range f.Meta {
|
for _, meta := range f.Meta {
|
||||||
if meta.Type == flac.VorbisComment {
|
if meta.Type != flac.VorbisComment {
|
||||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
continue
|
||||||
if err != nil {
|
}
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err != nil {
|
||||||
return lyrics[0], nil
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("no lyrics found in file")
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func looksLikeEmbeddedLyrics(value string) bool {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
@@ -512,371 +692,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// M4A (MP4/AAC) Metadata Embedding
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
|
||||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
|
||||||
input, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open M4A file: %w", err)
|
|
||||||
}
|
|
||||||
defer input.Close()
|
|
||||||
|
|
||||||
info, err := input.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to stat M4A file: %w", err)
|
|
||||||
}
|
|
||||||
fileSize := info.Size()
|
|
||||||
|
|
||||||
moovHeader, moovFound, err := findAtomInRange(input, 0, fileSize, "moov", fileSize)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to find moov atom: %w", err)
|
|
||||||
}
|
|
||||||
if !moovFound {
|
|
||||||
return fmt.Errorf("moov atom not found in M4A file")
|
|
||||||
}
|
|
||||||
|
|
||||||
moovContentStart := moovHeader.offset + moovHeader.headerSize
|
|
||||||
moovContentSize := moovHeader.size - moovHeader.headerSize
|
|
||||||
|
|
||||||
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to locate udta atom: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metaHeader atomHeader
|
|
||||||
metaFound := false
|
|
||||||
if udtaFound {
|
|
||||||
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
|
|
||||||
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
|
|
||||||
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to locate meta atom: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metaAtom := buildMetaAtom(metadata, coverData)
|
|
||||||
metaSize := int64(len(metaAtom))
|
|
||||||
|
|
||||||
var delta int64
|
|
||||||
var newUdtaSize int64
|
|
||||||
switch {
|
|
||||||
case udtaFound && metaFound:
|
|
||||||
delta = metaSize - metaHeader.size
|
|
||||||
newUdtaSize = udtaHeader.size + delta
|
|
||||||
case udtaFound && !metaFound:
|
|
||||||
delta = metaSize
|
|
||||||
newUdtaSize = udtaHeader.size + delta
|
|
||||||
case !udtaFound:
|
|
||||||
newUdtaSize = int64(8 + len(metaAtom))
|
|
||||||
delta = newUdtaSize
|
|
||||||
}
|
|
||||||
|
|
||||||
newMoovSize := moovHeader.size + delta
|
|
||||||
if moovHeader.headerSize == 8 && newMoovSize > int64(^uint32(0)) {
|
|
||||||
return fmt.Errorf("moov atom exceeds 32-bit size after update")
|
|
||||||
}
|
|
||||||
if udtaFound && udtaHeader.headerSize == 8 && newUdtaSize > int64(^uint32(0)) {
|
|
||||||
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
|
||||||
}
|
|
||||||
if !udtaFound && newUdtaSize > int64(^uint32(0)) {
|
|
||||||
return fmt.Errorf("udta atom exceeds 32-bit size after update")
|
|
||||||
}
|
|
||||||
|
|
||||||
tempPath := filePath + ".tmp"
|
|
||||||
output, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
cleanupTemp := true
|
|
||||||
defer func() {
|
|
||||||
_ = output.Close()
|
|
||||||
if cleanupTemp {
|
|
||||||
_ = os.Remove(tempPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case udtaFound && metaFound:
|
|
||||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, metaHeader.offset-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := output.Write(metaAtom); err != nil {
|
|
||||||
return fmt.Errorf("failed to write meta atom: %w", err)
|
|
||||||
}
|
|
||||||
metaEnd := metaHeader.offset + metaHeader.size
|
|
||||||
if err := copyRange(output, input, metaEnd, fileSize-metaEnd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case udtaFound && !metaFound:
|
|
||||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, udtaHeader.offset-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "udta", newUdtaSize, udtaHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
insertPos := udtaHeader.offset + udtaHeader.size
|
|
||||||
if err := copyRange(output, input, udtaHeader.offset+udtaHeader.headerSize, insertPos-(udtaHeader.offset+udtaHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := output.Write(metaAtom); err != nil {
|
|
||||||
return fmt.Errorf("failed to write meta atom: %w", err)
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, insertPos, fileSize-insertPos); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case !udtaFound:
|
|
||||||
newUdtaAtom := buildUdtaAtom(metaAtom)
|
|
||||||
if err := copyRange(output, input, 0, moovHeader.offset); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := writeAtomHeader(output, "moov", newMoovSize, moovHeader.headerSize); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
moovEnd := moovHeader.offset + moovHeader.size
|
|
||||||
if err := copyRange(output, input, moovHeader.offset+moovHeader.headerSize, moovEnd-(moovHeader.offset+moovHeader.headerSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := output.Write(newUdtaAtom); err != nil {
|
|
||||||
return fmt.Errorf("failed to write udta atom: %w", err)
|
|
||||||
}
|
|
||||||
if err := copyRange(output, input, moovEnd, fileSize-moovEnd); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := output.Close(); err != nil {
|
|
||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = input.Close()
|
|
||||||
if err := os.Remove(filePath); err != nil {
|
|
||||||
return fmt.Errorf("failed to replace original file: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tempPath, filePath); err != nil {
|
|
||||||
return fmt.Errorf("failed to move temp file: %w", err)
|
|
||||||
}
|
|
||||||
cleanupTemp = false
|
|
||||||
|
|
||||||
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAtom(data []byte, name string, offset int) int {
|
|
||||||
for i := offset; i < len(data)-8; {
|
|
||||||
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
|
||||||
if size < 8 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
atomName := string(data[i+4 : i+8])
|
|
||||||
if atomName == name {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
i += size
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
|
||||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|
||||||
var ilst []byte
|
|
||||||
|
|
||||||
if metadata.Title != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Artist != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Album != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.AlbumArtist != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Date != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
|
||||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
|
||||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Lyrics != "" {
|
|
||||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(coverData) > 0 {
|
|
||||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
ilstSize := 8 + len(ilst)
|
|
||||||
ilstAtom := make([]byte, 4)
|
|
||||||
ilstAtom[0] = byte(ilstSize >> 24)
|
|
||||||
ilstAtom[1] = byte(ilstSize >> 16)
|
|
||||||
ilstAtom[2] = byte(ilstSize >> 8)
|
|
||||||
ilstAtom[3] = byte(ilstSize)
|
|
||||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
|
||||||
ilstAtom = append(ilstAtom, ilst...)
|
|
||||||
|
|
||||||
hdlr := []byte{
|
|
||||||
0, 0, 0, 33, // size = 33
|
|
||||||
'h', 'd', 'l', 'r',
|
|
||||||
0, 0, 0, 0, // version + flags
|
|
||||||
0, 0, 0, 0, // predefined
|
|
||||||
'm', 'd', 'i', 'r', // handler type
|
|
||||||
'a', 'p', 'p', 'l', // manufacturer
|
|
||||||
0, 0, 0, 0, // component flags
|
|
||||||
0, 0, 0, 0, // component flags mask
|
|
||||||
0, // null terminator
|
|
||||||
}
|
|
||||||
|
|
||||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
|
||||||
metaContent = append(metaContent, ilstAtom...)
|
|
||||||
|
|
||||||
metaSize := 8 + len(metaContent)
|
|
||||||
metaAtom := make([]byte, 4)
|
|
||||||
metaAtom[0] = byte(metaSize >> 24)
|
|
||||||
metaAtom[1] = byte(metaSize >> 16)
|
|
||||||
metaAtom[2] = byte(metaSize >> 8)
|
|
||||||
metaAtom[3] = byte(metaSize)
|
|
||||||
metaAtom = append(metaAtom, []byte("meta")...)
|
|
||||||
metaAtom = append(metaAtom, metaContent...)
|
|
||||||
|
|
||||||
return metaAtom
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTextAtom(name, value string) []byte {
|
|
||||||
valueBytes := []byte(value)
|
|
||||||
|
|
||||||
dataSize := 16 + len(valueBytes)
|
|
||||||
dataAtom := make([]byte, 4)
|
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
|
||||||
dataAtom[1] = byte(dataSize >> 16)
|
|
||||||
dataAtom[2] = byte(dataSize >> 8)
|
|
||||||
dataAtom[3] = byte(dataSize)
|
|
||||||
dataAtom = append(dataAtom, []byte("data")...)
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
|
||||||
dataAtom = append(dataAtom, valueBytes...)
|
|
||||||
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte(name)...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildTrackNumberAtom builds trkn atom
|
|
||||||
func buildTrackNumberAtom(track, total int) []byte {
|
|
||||||
dataAtom := []byte{
|
|
||||||
0, 0, 0, 24, // size
|
|
||||||
'd', 'a', 't', 'a',
|
|
||||||
0, 0, 0, 0, // type = implicit
|
|
||||||
0, 0, 0, 0, // locale
|
|
||||||
0, 0, // padding
|
|
||||||
byte(track >> 8), byte(track), // track number
|
|
||||||
byte(total >> 8), byte(total), // total tracks
|
|
||||||
0, 0, // padding
|
|
||||||
}
|
|
||||||
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte("trkn")...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDiscNumberAtom(disc, total int) []byte {
|
|
||||||
dataAtom := []byte{
|
|
||||||
0, 0, 0, 22, // size
|
|
||||||
'd', 'a', 't', 'a',
|
|
||||||
0, 0, 0, 0, // type = implicit
|
|
||||||
0, 0, 0, 0, // locale
|
|
||||||
0, 0, // padding
|
|
||||||
byte(disc >> 8), byte(disc), // disc number
|
|
||||||
byte(total >> 8), byte(total), // total discs
|
|
||||||
}
|
|
||||||
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte("disk")...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildCoverAtom builds covr atom with image data
|
|
||||||
func buildCoverAtom(coverData []byte) []byte {
|
|
||||||
imageType := byte(13)
|
|
||||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
|
||||||
imageType = 14
|
|
||||||
}
|
|
||||||
|
|
||||||
dataSize := 16 + len(coverData)
|
|
||||||
dataAtom := make([]byte, 4)
|
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
|
||||||
dataAtom[1] = byte(dataSize >> 16)
|
|
||||||
dataAtom[2] = byte(dataSize >> 8)
|
|
||||||
dataAtom[3] = byte(dataSize)
|
|
||||||
dataAtom = append(dataAtom, []byte("data")...)
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, imageType)
|
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 0)
|
|
||||||
dataAtom = append(dataAtom, coverData...)
|
|
||||||
|
|
||||||
atomSize := 8 + len(dataAtom)
|
|
||||||
atom := make([]byte, 4)
|
|
||||||
atom[0] = byte(atomSize >> 24)
|
|
||||||
atom[1] = byte(atomSize >> 16)
|
|
||||||
atom[2] = byte(atomSize >> 8)
|
|
||||||
atom[3] = byte(atomSize)
|
|
||||||
atom = append(atom, []byte("covr")...)
|
|
||||||
atom = append(atom, dataAtom...)
|
|
||||||
|
|
||||||
return atom
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -989,52 +804,6 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
|
|||||||
return atomHeader{}, false, nil
|
return atomHeader{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeAtomHeader(w io.Writer, typ string, size int64, headerSize int64) error {
|
|
||||||
if len(typ) != 4 {
|
|
||||||
return fmt.Errorf("invalid atom type: %s", typ)
|
|
||||||
}
|
|
||||||
|
|
||||||
if headerSize == 16 {
|
|
||||||
header := make([]byte, 16)
|
|
||||||
binary.BigEndian.PutUint32(header[0:4], 1)
|
|
||||||
copy(header[4:8], []byte(typ))
|
|
||||||
binary.BigEndian.PutUint64(header[8:16], uint64(size))
|
|
||||||
_, err := w.Write(header)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if size > int64(^uint32(0)) {
|
|
||||||
return fmt.Errorf("atom size exceeds 32-bit for %s", typ)
|
|
||||||
}
|
|
||||||
|
|
||||||
header := make([]byte, 8)
|
|
||||||
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
|
||||||
copy(header[4:8], []byte(typ))
|
|
||||||
_, err := w.Write(header)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyRange(dst io.Writer, src *os.File, offset, length int64) error {
|
|
||||||
if length <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if _, err := src.Seek(offset, io.SeekStart); err != nil {
|
|
||||||
return fmt.Errorf("failed to seek source: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := io.CopyN(dst, src, length); err != nil {
|
|
||||||
return fmt.Errorf("failed to copy data: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildUdtaAtom(metaAtom []byte) []byte {
|
|
||||||
size := 8 + len(metaAtom)
|
|
||||||
header := make([]byte, 8)
|
|
||||||
binary.BigEndian.PutUint32(header[0:4], uint32(size))
|
|
||||||
copy(header[4:8], []byte("udta"))
|
|
||||||
return append(header, metaAtom...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
||||||
const chunkSize = 64 * 1024
|
const chunkSize = 64 * 1024
|
||||||
patternMP4A := []byte("mp4a")
|
patternMP4A := []byte("mp4a")
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// mobile_deps.go
|
||||||
|
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
||||||
|
// These packages are required by gomobile bind but not directly imported in code.
|
||||||
|
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
// Required for gomobile bind to work
|
||||||
|
_ "golang.org/x/mobile/bind"
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isFDOutput(outputFD int) bool {
|
||||||
|
return outputFD > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
||||||
|
if isFDOutput(outputFD) {
|
||||||
|
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
|
||||||
|
}
|
||||||
|
return os.Create(outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupOutputOnError(outputPath string, outputFD int) {
|
||||||
|
if isFDOutput(outputFD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimSpace(outputPath)
|
||||||
|
if path == "" || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,14 +10,16 @@ import (
|
|||||||
type TrackIDCacheEntry struct {
|
type TrackIDCacheEntry struct {
|
||||||
TidalTrackID int64
|
TidalTrackID int64
|
||||||
QobuzTrackID int64
|
QobuzTrackID int64
|
||||||
AmazonTrackID string
|
AmazonURL string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackIDCache struct {
|
type TrackIDCache struct {
|
||||||
cache map[string]*TrackIDCacheEntry
|
cache map[string]*TrackIDCacheEntry
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
|
lastCleanup time.Time
|
||||||
|
cleanupInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -27,8 +30,9 @@ var (
|
|||||||
func GetTrackIDCache() *TrackIDCache {
|
func GetTrackIDCache() *TrackIDCache {
|
||||||
trackIDCacheOnce.Do(func() {
|
trackIDCacheOnce.Do(func() {
|
||||||
globalTrackIDCache = &TrackIDCache{
|
globalTrackIDCache = &TrackIDCache{
|
||||||
cache: make(map[string]*TrackIDCacheEntry),
|
cache: make(map[string]*TrackIDCacheEntry),
|
||||||
ttl: 30 * time.Minute,
|
ttl: 30 * time.Minute,
|
||||||
|
cleanupInterval: 5 * time.Minute,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalTrackIDCache
|
return globalTrackIDCache
|
||||||
@@ -36,13 +40,33 @@ func GetTrackIDCache() *TrackIDCache {
|
|||||||
|
|
||||||
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
entry, exists := c.cache[isrc]
|
entry, exists := c.cache[isrc]
|
||||||
if !exists || time.Now().After(entry.ExpiresAt) {
|
if !exists {
|
||||||
|
c.mu.RUnlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return entry
|
expired := time.Now().After(entry.ExpiresAt)
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if !expired {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
entry, exists = c.cache[isrc]
|
||||||
|
if exists && time.Now().After(entry.ExpiresAt) {
|
||||||
|
delete(c.cache, isrc)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
|
||||||
|
for key, entry := range c.cache {
|
||||||
|
if now.After(entry.ExpiresAt) {
|
||||||
|
delete(c.cache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||||
@@ -55,7 +79,13 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
|||||||
c.cache[isrc] = entry
|
c.cache[isrc] = entry
|
||||||
}
|
}
|
||||||
entry.TidalTrackID = trackID
|
entry.TidalTrackID = trackID
|
||||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
now := time.Now()
|
||||||
|
entry.ExpiresAt = now.Add(c.ttl)
|
||||||
|
|
||||||
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||||
|
c.pruneExpiredLocked(now)
|
||||||
|
c.lastCleanup = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||||
@@ -68,10 +98,16 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
|||||||
c.cache[isrc] = entry
|
c.cache[isrc] = entry
|
||||||
}
|
}
|
||||||
entry.QobuzTrackID = trackID
|
entry.QobuzTrackID = trackID
|
||||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
now := time.Now()
|
||||||
|
entry.ExpiresAt = now.Add(c.ttl)
|
||||||
|
|
||||||
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||||
|
c.pruneExpiredLocked(now)
|
||||||
|
c.lastCleanup = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
@@ -80,8 +116,14 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
|||||||
entry = &TrackIDCacheEntry{}
|
entry = &TrackIDCacheEntry{}
|
||||||
c.cache[isrc] = entry
|
c.cache[isrc] = entry
|
||||||
}
|
}
|
||||||
entry.AmazonTrackID = trackID
|
entry.AmazonURL = amazonURL
|
||||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
now := time.Now()
|
||||||
|
entry.ExpiresAt = now.Add(c.ttl)
|
||||||
|
|
||||||
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||||
|
c.pruneExpiredLocked(now)
|
||||||
|
c.lastCleanup = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *TrackIDCache) Clear() {
|
func (c *TrackIDCache) Clear() {
|
||||||
@@ -96,7 +138,6 @@ func (c *TrackIDCache) Size() int {
|
|||||||
return len(c.cache)
|
return len(c.cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParallelDownloadResult holds results from parallel operations
|
|
||||||
type ParallelDownloadResult struct {
|
type ParallelDownloadResult struct {
|
||||||
CoverData []byte
|
CoverData []byte
|
||||||
LyricsData *LyricsResponse
|
LyricsData *LyricsResponse
|
||||||
@@ -116,20 +157,20 @@ func FetchCoverAndLyricsParallel(
|
|||||||
) *ParallelDownloadResult {
|
) *ParallelDownloadResult {
|
||||||
result := &ParallelDownloadResult{}
|
result := &ParallelDownloadResult{}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
if coverURL != "" {
|
if coverURL != "" {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Println("[Parallel] Starting cover download...")
|
|
||||||
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||||
|
resultMu.Lock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.CoverErr = err
|
result.CoverErr = err
|
||||||
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
|
|
||||||
} else {
|
} else {
|
||||||
result.CoverData = data
|
result.CoverData = data
|
||||||
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
|
|
||||||
}
|
}
|
||||||
|
resultMu.Unlock()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,21 +178,19 @@ func FetchCoverAndLyricsParallel(
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Println("[Parallel] Starting lyrics fetch...")
|
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
durationSec := float64(durationMs) / 1000.0
|
durationSec := float64(durationMs) / 1000.0
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
|
resultMu.Lock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.LyricsErr = err
|
result.LyricsErr = err
|
||||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
|
||||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
result.LyricsData = lyrics
|
result.LyricsData = lyrics
|
||||||
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
|
||||||
} else {
|
} else {
|
||||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||||
fmt.Println("[Parallel] No lyrics found")
|
|
||||||
}
|
}
|
||||||
|
resultMu.Unlock()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,8 +202,8 @@ type PreWarmCacheRequest struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
TrackName string
|
TrackName string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
SpotifyID string // Needed for Amazon (SongLink lookup)
|
SpotifyID string
|
||||||
Service string // "tidal", "qobuz", "amazon"
|
Service string
|
||||||
}
|
}
|
||||||
|
|
||||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||||
@@ -172,13 +211,15 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
|
||||||
cache := GetTrackIDCache()
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
semaphore := make(chan struct{}, 3)
|
semaphore := make(chan struct{}, 3)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, req := range requests {
|
for _, req := range requests {
|
||||||
|
if req.ISRC == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if cached := cache.Get(req.ISRC); cached != nil {
|
if cached := cache.Get(req.ISRC); cached != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -193,7 +234,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
case "tidal":
|
case "tidal":
|
||||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
preWarmQobuzCache(r.ISRC)
|
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||||
case "amazon":
|
case "amazon":
|
||||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||||
}
|
}
|
||||||
@@ -201,7 +242,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmTidalCache(isrc, _, _ string) {
|
func preWarmTidalCache(isrc, _, _ string) {
|
||||||
@@ -209,30 +249,68 @@ func preWarmTidalCache(isrc, _, _ string) {
|
|||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
GetTrackIDCache().SetTidal(isrc, track.ID)
|
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||||
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmQobuzCache(isrc string) {
|
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
|
||||||
|
// 1. From SongLink (fast, no Qobuz API call needed)
|
||||||
|
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||||
|
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||||
|
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
||||||
|
if spotifyID != "" {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
|
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||||
|
// Parse QobuzID to int64
|
||||||
|
var trackID int64
|
||||||
|
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||||
|
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||||
|
GetTrackIDCache().SetQobuz(isrc, trackID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Direct ISRC search on Qobuz API
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
track, err := downloader.SearchTrackByISRC(isrc)
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
|
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
|
||||||
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||||
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
if err == nil && availability != nil && availability.Amazon {
|
if err == nil && availability != nil && availability.AmazonURL != "" {
|
||||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
|
||||||
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PreWarmCache(tracksJSON string) error {
|
func PreWarmCache(tracksJSON string) error {
|
||||||
var requests []PreWarmCacheRequest
|
var tracks []struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||||
|
for i, t := range tracks {
|
||||||
|
requests[i] = PreWarmCacheRequest{
|
||||||
|
ISRC: t.ISRC,
|
||||||
|
TrackName: t.TrackName,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
SpotifyID: t.SpotifyID,
|
||||||
|
Service: t.Service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
go PreWarmTrackCache(requests)
|
go PreWarmTrackCache(requests)
|
||||||
return nil
|
return nil
|
||||||
@@ -240,7 +318,6 @@ func PreWarmCache(tracksJSON string) error {
|
|||||||
|
|
||||||
func ClearTrackCache() {
|
func ClearTrackCache() {
|
||||||
GetTrackIDCache().Clear()
|
GetTrackIDCache().Clear()
|
||||||
fmt.Println("[Cache] Track ID cache cleared")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCacheSize() int {
|
func GetCacheSize() int {
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ func GetItemProgress(itemID string) string {
|
|||||||
return "{}"
|
return "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartItemProgress initializes progress tracking for an item
|
|
||||||
func StartItemProgress(itemID string) {
|
func StartItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -93,7 +92,6 @@ func StartItemProgress(itemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemBytesTotal sets total bytes for an item
|
|
||||||
func SetItemBytesTotal(itemID string, total int64) {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -103,7 +101,6 @@ func SetItemBytesTotal(itemID string, total int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemBytesReceived sets bytes received for an item
|
|
||||||
func SetItemBytesReceived(itemID string, received int64) {
|
func SetItemBytesReceived(itemID string, received int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -116,7 +113,6 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
|
||||||
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -130,7 +126,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteItemProgress marks an item as complete
|
|
||||||
func CompleteItemProgress(itemID string) {
|
func CompleteItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -142,7 +137,6 @@ func CompleteItemProgress(itemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemProgress sets progress for an item directly
|
|
||||||
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -158,7 +152,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
|
||||||
func SetItemFinalizing(itemID string) {
|
func SetItemFinalizing(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -169,7 +162,6 @@ func SetItemFinalizing(itemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveItemProgress removes progress tracking for an item
|
|
||||||
func RemoveItemProgress(itemID string) {
|
func RemoveItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -177,7 +169,6 @@ func RemoveItemProgress(itemID string) {
|
|||||||
delete(multiProgress.Items, itemID)
|
delete(multiProgress.Items, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAllItemProgress clears all item progress
|
|
||||||
func ClearAllItemProgress() {
|
func ClearAllItemProgress() {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
@@ -185,7 +176,6 @@ func ClearAllItemProgress() {
|
|||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDownloadDir sets the default download directory
|
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
downloadDirMu.Lock()
|
downloadDirMu.Lock()
|
||||||
defer downloadDirMu.Unlock()
|
defer downloadDirMu.Unlock()
|
||||||
@@ -193,20 +183,18 @@ func setDownloadDir(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
current int64
|
current int64
|
||||||
lastReported int64 // Track last reported bytes for threshold-based updates
|
lastReported int64
|
||||||
startTime time.Time // Track start time for speed calculation
|
startTime time.Time
|
||||||
lastTime time.Time // Track last update time for speed calculation
|
lastTime time.Time
|
||||||
lastBytes int64 // Track bytes at last speed calculation
|
lastBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
const progressUpdateThreshold = 64 * 1024
|
||||||
|
|
||||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &ItemProgressWriter{
|
return &ItemProgressWriter{
|
||||||
@@ -220,7 +208,6 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||||
return 0, ErrDownloadCancelled
|
return 0, ErrDownloadCancelled
|
||||||
|
|||||||
@@ -52,12 +52,10 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -112,24 +110,19 @@ func qobuzSplitArtists(artists string) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
|
|
||||||
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
|
|
||||||
func qobuzSameWordsUnordered(a, b string) bool {
|
func qobuzSameWordsUnordered(a, b string) bool {
|
||||||
wordsA := strings.Fields(a)
|
wordsA := strings.Fields(a)
|
||||||
wordsB := strings.Fields(b)
|
wordsB := strings.Fields(b)
|
||||||
|
|
||||||
// Must have same number of words
|
|
||||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort and compare
|
|
||||||
sortedA := make([]string, len(wordsA))
|
sortedA := make([]string, len(wordsA))
|
||||||
sortedB := make([]string, len(wordsB))
|
sortedB := make([]string, len(wordsB))
|
||||||
copy(sortedA, wordsA)
|
copy(sortedA, wordsA)
|
||||||
copy(sortedB, wordsB)
|
copy(sortedB, wordsB)
|
||||||
|
|
||||||
// Simple bubble sort (usually just 2-3 words)
|
|
||||||
for i := 0; i < len(sortedA)-1; i++ {
|
for i := 0; i < len(sortedA)-1; i++ {
|
||||||
for j := i + 1; j < len(sortedA); j++ {
|
for j := i + 1; j < len(sortedA); j++ {
|
||||||
if sortedA[i] > sortedA[j] {
|
if sortedA[i] > sortedA[j] {
|
||||||
@@ -153,7 +146,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -182,8 +174,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
|
||||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
|
||||||
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
||||||
foundLatin := qobuzIsLatinScript(foundTitle)
|
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||||
if expectedLatin != foundLatin {
|
if expectedLatin != foundLatin {
|
||||||
@@ -194,9 +184,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
|
|
||||||
func qobuzExtractCoreTitle(title string) string {
|
func qobuzExtractCoreTitle(title string) string {
|
||||||
// Find first occurrence of ( or [
|
|
||||||
parenIdx := strings.Index(title, "(")
|
parenIdx := strings.Index(title, "(")
|
||||||
bracketIdx := strings.Index(title, "[")
|
bracketIdx := strings.Index(title, "[")
|
||||||
dashIdx := strings.Index(title, " - ")
|
dashIdx := strings.Index(title, " - ")
|
||||||
@@ -281,49 +269,28 @@ func qobuzCleanTitle(title string) string {
|
|||||||
return strings.TrimSpace(cleaned)
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzIsLatinScript checks if a string is primarily Latin script
|
|
||||||
// Returns true for ASCII and Latin Extended characters (European languages)
|
|
||||||
// Returns false for CJK, Arabic, Cyrillic, etc.
|
|
||||||
func qobuzIsLatinScript(s string) bool {
|
func qobuzIsLatinScript(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
// Skip common punctuation and numbers
|
|
||||||
if r < 128 {
|
if r < 128 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
if (r >= 0x0100 && r <= 0x024F) ||
|
||||||
// Latin Extended-B: U+0180 to U+024F
|
(r >= 0x1E00 && r <= 0x1EFF) ||
|
||||||
// Latin Extended Additional: U+1E00 to U+1EFF
|
(r >= 0x00C0 && r <= 0x00FF) {
|
||||||
// Latin Extended-C/D/E: various ranges
|
|
||||||
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
|
||||||
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
|
||||||
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// CJK ranges - definitely different script
|
if (r >= 0x4E00 && r <= 0x9FFF) ||
|
||||||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
(r >= 0x3040 && r <= 0x309F) ||
|
||||||
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
(r >= 0x30A0 && r <= 0x30FF) ||
|
||||||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
(r >= 0xAC00 && r <= 0xD7AF) ||
|
||||||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
(r >= 0x0600 && r <= 0x06FF) ||
|
||||||
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
(r >= 0x0400 && r <= 0x04FF) {
|
||||||
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
|
||||||
// Kept for potential future use
|
|
||||||
// func qobuzIsASCIIString(s string) bool {
|
|
||||||
// for _, r := range s {
|
|
||||||
// if r > 127 {
|
|
||||||
// return false
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return true
|
|
||||||
// }
|
|
||||||
|
|
||||||
// containsQueryQobuz checks if a query already exists in the list
|
|
||||||
func containsQueryQobuz(queries []string, query string) bool {
|
func containsQueryQobuz(queries []string, query string) bool {
|
||||||
for _, q := range queries {
|
for _, q := range queries {
|
||||||
if q == query {
|
if q == query {
|
||||||
@@ -336,7 +303,7 @@ func containsQueryQobuz(queries []string, query string) bool {
|
|||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
qobuzDownloaderOnce.Do(func() {
|
qobuzDownloaderOnce.Do(func() {
|
||||||
globalQobuzDownloader = &QobuzDownloader{
|
globalQobuzDownloader = &QobuzDownloader{
|
||||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
client: NewHTTPClientWithTimeout(DefaultTimeout),
|
||||||
appID: "798273057",
|
appID: "798273057",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -344,7 +311,6 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||||
// Qobuz API: /track/get?track_id=XXX
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
||||||
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
|
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
|
||||||
|
|
||||||
@@ -371,14 +337,11 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
|||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
|
||||||
// Uses same APIs as PC version for compatibility
|
|
||||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||||
// Same APIs as PC version (referensi/backend/qobuz.go)
|
|
||||||
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
|
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==",
|
||||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
|
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=",
|
||||||
|
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=",
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -393,6 +356,124 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
|||||||
return apis
|
return apis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapJumoQuality(quality string) int {
|
||||||
|
switch quality {
|
||||||
|
case "6":
|
||||||
|
return 6
|
||||||
|
case "7":
|
||||||
|
return 7
|
||||||
|
case "27":
|
||||||
|
return 27
|
||||||
|
default:
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeXOR(data []byte) string {
|
||||||
|
text := string(data)
|
||||||
|
runes := []rune(text)
|
||||||
|
result := make([]rune, len(runes))
|
||||||
|
for i, char := range runes {
|
||||||
|
key := rune((i * 17) % 128)
|
||||||
|
result[i] = char ^ 253 ^ key
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||||
|
return "", fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if success, ok := raw["success"].(bool); ok && !success {
|
||||||
|
if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||||
|
return "", fmt.Errorf("%s", msg)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("api returned success=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
|
return strings.TrimSpace(urlVal), nil
|
||||||
|
}
|
||||||
|
if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||||
|
return strings.TrimSpace(linkVal), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, ok := raw["data"].(map[string]any); ok {
|
||||||
|
if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
|
return strings.TrimSpace(urlVal), nil
|
||||||
|
}
|
||||||
|
if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
||||||
|
return strings.TrimSpace(linkVal), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no download URL in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||||
|
formatID := mapJumoQuality(quality)
|
||||||
|
region := "US"
|
||||||
|
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Trying Jumo API fallback...\n")
|
||||||
|
|
||||||
|
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||||
|
req, err := http.NewRequest("GET", jumoURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("Jumo API returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
decoded := decodeXOR(body)
|
||||||
|
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if urlVal, ok := result["url"].(string); ok && urlVal != "" {
|
||||||
|
GoLog("[Qobuz] Jumo API returned URL successfully\n")
|
||||||
|
return urlVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, ok := result["data"].(map[string]any); ok {
|
||||||
|
if urlVal, ok := data["url"].(string); ok && urlVal != "" {
|
||||||
|
GoLog("[Qobuz] Jumo API returned URL successfully (from data)\n")
|
||||||
|
return urlVal, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if linkVal, ok := result["link"].(string); ok && linkVal != "" {
|
||||||
|
GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n")
|
||||||
|
return linkVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("URL not found in Jumo response")
|
||||||
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
@@ -421,7 +502,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find exact ISRC match
|
|
||||||
for i := range result.Tracks.Items {
|
for i := range result.Tracks.Items {
|
||||||
if result.Tracks.Items[i].ISRC == isrc {
|
if result.Tracks.Items[i].ISRC == isrc {
|
||||||
return &result.Tracks.Items[i], nil
|
return &result.Tracks.Items[i], nil
|
||||||
@@ -435,7 +515,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
|
||||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||||
|
|
||||||
@@ -468,7 +547,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
|
|
||||||
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||||
|
|
||||||
// Find ISRC matches
|
|
||||||
var isrcMatches []*QobuzTrack
|
var isrcMatches []*QobuzTrack
|
||||||
for i := range result.Tracks.Items {
|
for i := range result.Tracks.Items {
|
||||||
if result.Tracks.Items[i].ISRC == isrc {
|
if result.Tracks.Items[i].ISRC == isrc {
|
||||||
@@ -522,35 +600,26 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now includes romaji conversion for Japanese text (same as Tidal)
|
|
||||||
// Also includes title verification to prevent wrong song downloads
|
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies (same as Tidal/PC version)
|
|
||||||
queries := []string{}
|
queries := []string{}
|
||||||
|
|
||||||
// Strategy 1: Artist + Track name
|
|
||||||
if artistName != "" && trackName != "" {
|
if artistName != "" && trackName != "" {
|
||||||
queries = append(queries, artistName+" "+trackName)
|
queries = append(queries, artistName+" "+trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Track name only
|
|
||||||
if trackName != "" {
|
if trackName != "" {
|
||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Romaji versions if Japanese detected
|
|
||||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||||
// Convert to romaji (hiragana/katakana only, kanji stays)
|
|
||||||
romajiTrack := JapaneseToRomaji(trackName)
|
romajiTrack := JapaneseToRomaji(trackName)
|
||||||
romajiArtist := JapaneseToRomaji(artistName)
|
romajiArtist := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
// Clean and remove ALL non-ASCII characters (including kanji)
|
|
||||||
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||||
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||||
|
|
||||||
// Artist + Track romaji (cleaned to ASCII only)
|
|
||||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
if !containsQueryQobuz(queries, romajiQuery) {
|
if !containsQueryQobuz(queries, romajiQuery) {
|
||||||
@@ -559,7 +628,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track romaji only (cleaned)
|
|
||||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||||
if !containsQueryQobuz(queries, cleanRomajiTrack) {
|
if !containsQueryQobuz(queries, cleanRomajiTrack) {
|
||||||
queries = append(queries, cleanRomajiTrack)
|
queries = append(queries, cleanRomajiTrack)
|
||||||
@@ -567,7 +635,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 4: Artist only as last resort
|
|
||||||
if artistName != "" {
|
if artistName != "" {
|
||||||
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||||
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
|
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
|
||||||
@@ -626,7 +693,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by title match first (NEW - like Tidal)
|
|
||||||
var titleMatches []*QobuzTrack
|
var titleMatches []*QobuzTrack
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
@@ -637,7 +703,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
|
|
||||||
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
||||||
|
|
||||||
// If no title matches, log warning but continue with all tracks
|
|
||||||
tracksToCheck := titleMatches
|
tracksToCheck := titleMatches
|
||||||
if len(titleMatches) == 0 {
|
if len(titleMatches) == 0 {
|
||||||
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
||||||
@@ -646,7 +711,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If duration verification is requested
|
|
||||||
if expectedDurationSec > 0 {
|
if expectedDurationSec > 0 {
|
||||||
var durationMatches []*QobuzTrack
|
var durationMatches []*QobuzTrack
|
||||||
for _, track := range tracksToCheck {
|
for _, track := range tracksToCheck {
|
||||||
@@ -662,12 +726,12 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
if len(durationMatches) > 0 {
|
if len(durationMatches) > 0 {
|
||||||
for _, track := range durationMatches {
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||||
track.Title, track.Performer.Name)
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n",
|
||||||
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
||||||
return durationMatches[0], nil
|
return durationMatches[0], nil
|
||||||
}
|
}
|
||||||
@@ -675,17 +739,16 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration verification, return best quality from title matches
|
|
||||||
for _, track := range tracksToCheck {
|
for _, track := range tracksToCheck {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||||
track.Title, track.Performer.Name)
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tracksToCheck) > 0 {
|
if len(tracksToCheck) > 0 {
|
||||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n",
|
||||||
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
||||||
return tracksToCheck[0], nil
|
return tracksToCheck[0], nil
|
||||||
}
|
}
|
||||||
@@ -693,7 +756,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzAPIResult holds the result from a parallel API request
|
|
||||||
type qobuzAPIResult struct {
|
type qobuzAPIResult struct {
|
||||||
apiURL string
|
apiURL string
|
||||||
downloadURL string
|
downloadURL string
|
||||||
@@ -701,90 +763,161 @@ type qobuzAPIResult struct {
|
|||||||
duration time.Duration
|
duration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Qobuz API timeout configuration
|
||||||
|
// Mobile networks are more unstable, so we use longer timeouts
|
||||||
|
const (
|
||||||
|
qobuzAPITimeoutMobile = 25 * time.Second
|
||||||
|
qobuzMaxRetries = 2 // Number of retries per API
|
||||||
|
qobuzRetryDelay = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// getQobuzAPITimeout returns appropriate timeout based on platform
|
||||||
|
// For mobile (gomobile builds), we use longer timeouts
|
||||||
|
func getQobuzAPITimeout() time.Duration {
|
||||||
|
// Since this runs in gomobile context, we always use mobile timeout
|
||||||
|
// The Go backend is only used on mobile (Android/iOS)
|
||||||
|
return qobuzAPITimeoutMobile
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzSquidCountries defines the region fallback order for squid.wtf API
|
||||||
|
var qobuzSquidCountries = []string{"US", "FR"}
|
||||||
|
|
||||||
|
// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic
|
||||||
|
// For squid.wtf APIs, it tries US region first, then falls back to FR
|
||||||
|
func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) {
|
||||||
|
isSquid := strings.Contains(api, "squid.wtf")
|
||||||
|
|
||||||
|
if isSquid {
|
||||||
|
for _, country := range qobuzSquidCountries {
|
||||||
|
GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country)
|
||||||
|
result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country)
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
|
||||||
|
func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) {
|
||||||
|
var lastErr error
|
||||||
|
retryDelay := qobuzRetryDelay
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= qobuzMaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay)
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
retryDelay *= 2 // Exponential backoff
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewHTTPClientWithTimeout(timeout)
|
||||||
|
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||||
|
if country != "" {
|
||||||
|
reqURL += "&country=" + country
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
// Check for retryable errors (timeout, connection reset)
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
if strings.Contains(errStr, "timeout") ||
|
||||||
|
strings.Contains(errStr, "reset") ||
|
||||||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
strings.Contains(errStr, "eof") {
|
||||||
|
continue // Retry
|
||||||
|
}
|
||||||
|
break // Non-retryable error
|
||||||
|
}
|
||||||
|
// Server errors are retryable
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429 rate limit - wait and retry
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
lastErr = fmt.Errorf("rate limited")
|
||||||
|
retryDelay = 2 * time.Second // Wait longer for rate limit
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) > 0 && body[0] == '<' {
|
||||||
|
return "", fmt.Errorf("received HTML instead of JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
urlVal, parseErr := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if parseErr == nil {
|
||||||
|
return urlVal, nil
|
||||||
|
}
|
||||||
|
lastErr = parseErr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("all retries failed")
|
||||||
|
}
|
||||||
|
|
||||||
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", "", fmt.Errorf("no APIs available")
|
return "", "", fmt.Errorf("no APIs available")
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
||||||
|
|
||||||
resultChan := make(chan qobuzAPIResult, len(apis))
|
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
timeout := getQobuzAPITimeout()
|
||||||
|
|
||||||
// Start all requests in parallel
|
|
||||||
for _, apiURL := range apis {
|
for _, apiURL := range apis {
|
||||||
go func(api string) {
|
go func(api string) {
|
||||||
reqStart := time.Now()
|
reqStart := time.Now()
|
||||||
|
downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout)
|
||||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
resultChan <- qobuzAPIResult{
|
||||||
|
apiURL: api,
|
||||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
downloadURL: downloadURL,
|
||||||
|
err: err,
|
||||||
req, err := http.NewRequest("GET", reqURL, nil)
|
duration: time.Since(reqStart),
|
||||||
if err != nil {
|
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if response is HTML (error page)
|
|
||||||
if len(body) > 0 && body[0] == '<' {
|
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for error in JSON response
|
|
||||||
var errorResp struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
|
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.URL != "" {
|
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)}
|
|
||||||
}(apiURL)
|
}(apiURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect results - return first success
|
|
||||||
var errors []string
|
var errors []string
|
||||||
|
|
||||||
for i := 0; i < len(apis); i++ {
|
for i := 0; i < len(apis); i++ {
|
||||||
result := <-resultChan
|
result := <-resultChan
|
||||||
if result.err == nil {
|
if result.err == nil {
|
||||||
GoLog("[Qobuz] [Parallel] ✓ Got response from %s in %v\n", result.apiURL, result.duration)
|
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration)
|
||||||
|
|
||||||
// Drain remaining results to avoid goroutine leaks
|
|
||||||
go func(remaining int) {
|
go func(remaining int) {
|
||||||
for j := 0; j < remaining; j++ {
|
for j := 0; j < remaining; j++ {
|
||||||
<-resultChan
|
<-resultChan
|
||||||
@@ -812,18 +945,38 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return "", err
|
return downloadURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return downloadURL, nil
|
GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
|
||||||
|
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
|
||||||
|
if jumoErr == nil {
|
||||||
|
return jumoURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if quality == "27" {
|
||||||
|
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
|
||||||
|
jumoURL, jumoErr = q.downloadFromJumo(trackID, "7")
|
||||||
|
if jumoErr == nil {
|
||||||
|
return jumoURL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if quality == "27" || quality == "7" {
|
||||||
|
GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n")
|
||||||
|
jumoURL, jumoErr = q.downloadFromJumo(trackID, "6")
|
||||||
|
if jumoErr == nil {
|
||||||
|
return jumoURL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -858,7 +1011,7 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := openOutputForWrite(outputPath, outputFD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -873,29 +1026,27 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush buffer before checking for errors
|
|
||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file size if Content-Length was provided
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,13 +1064,17 @@ type QobuzDownloadResult struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
LyricsLRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
if !isSafOutput {
|
||||||
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedDurationSec := req.DurationMS / 1000
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
@@ -927,6 +1082,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
|
||||||
if req.QobuzID != "" {
|
if req.QobuzID != "" {
|
||||||
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
||||||
var trackID int64
|
var trackID int64
|
||||||
@@ -941,24 +1097,46 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPTIMIZATION: Check cache first for track ID
|
// Strategy 2: Use cached Qobuz Track ID (fast, no search needed)
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||||
// For Qobuz we need to search again to get full track info, but we can use the ID
|
track, err = downloader.GetTrackByID(cached.QobuzTrackID)
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC with duration verification
|
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
|
||||||
|
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
|
||||||
|
GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||||
|
songLinkClient := NewSongLinkClient()
|
||||||
|
availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
|
if slErr == nil && availability != nil && availability.QobuzID != "" {
|
||||||
|
var trackID int64
|
||||||
|
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||||
|
GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID)
|
||||||
|
track, err = downloader.GetTrackByID(trackID)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err)
|
||||||
|
track = nil
|
||||||
|
} else if track != nil {
|
||||||
|
GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
|
||||||
|
// Cache for future use
|
||||||
|
if req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: ISRC search with duration verification
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
// Verify artist AND title
|
|
||||||
if track != nil {
|
if track != nil {
|
||||||
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
@@ -972,10 +1150,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata with duration verification (includes title verification)
|
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
|
||||||
if track == nil {
|
if track == nil {
|
||||||
|
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
|
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, track.Performer.Name)
|
req.ArtistName, track.Performer.Name)
|
||||||
@@ -991,7 +1169,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found and cache the track ID
|
|
||||||
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
@@ -1005,29 +1182,33 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
var outputPath string
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
if isSafOutput {
|
||||||
|
outputPath = strings.TrimSpace(req.OutputPath)
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map quality from Tidal format to Qobuz format
|
qobuzQuality := "27"
|
||||||
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
|
|
||||||
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
|
|
||||||
qobuzQuality := "27" // Default to highest quality
|
|
||||||
switch req.Quality {
|
switch req.Quality {
|
||||||
case "LOSSLESS":
|
case "LOSSLESS":
|
||||||
qobuzQuality = "6" // 16-bit FLAC
|
qobuzQuality = "6"
|
||||||
case "HI_RES":
|
case "HI_RES":
|
||||||
qobuzQuality = "7" // 24-bit 96kHz
|
qobuzQuality = "7"
|
||||||
case "HI_RES_LOSSLESS":
|
case "HI_RES_LOSSLESS":
|
||||||
qobuzQuality = "27" // 24-bit 192kHz
|
qobuzQuality = "27"
|
||||||
}
|
}
|
||||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
actualBitDepth := track.MaximumBitDepth
|
actualBitDepth := track.MaximumBitDepth
|
||||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
actualSampleRate := int(track.MaximumSamplingRate * 1000)
|
||||||
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
@@ -1035,7 +1216,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
|
||||||
var parallelResult *ParallelDownloadResult
|
var parallelResult *ParallelDownloadResult
|
||||||
parallelDone := make(chan struct{})
|
parallelDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1051,15 +1231,13 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
|
||||||
if errors.Is(err, ErrDownloadCancelled) {
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
return QobuzDownloadResult{}, ErrDownloadCancelled
|
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
@@ -1072,19 +1250,24 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
albumName = req.AlbumName
|
albumName = req.AlbumName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actualTrackNumber := req.TrackNumber
|
||||||
|
if actualTrackNumber == 0 {
|
||||||
|
actualTrackNumber = track.TrackNumber
|
||||||
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
Artist: track.Performer.Name,
|
Artist: track.Performer.Name,
|
||||||
Album: albumName,
|
Album: albumName,
|
||||||
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: track.Album.ReleaseDate,
|
Date: track.Album.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
DiscNumber: req.DiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
Genre: req.Genre, // From Deezer album metadata
|
Genre: req.Genre,
|
||||||
Label: req.Label, // From Deezer album metadata
|
Label: req.Label,
|
||||||
Copyright: req.Copyright, // From Deezer album metadata
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
@@ -1093,40 +1276,50 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if isSafOutput {
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||||
|
} else {
|
||||||
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsMode := req.LyricsMode
|
||||||
|
if lyricsMode == "" {
|
||||||
|
lyricsMode = "embed"
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
|
GoLog("[Qobuz] Saving external LRC file...\n")
|
||||||
|
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
|
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||||
|
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
|
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
|
} else {
|
||||||
|
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isSafOutput {
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsLRC := ""
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
lyricsMode := req.LyricsMode
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
if lyricsMode == "" {
|
|
||||||
lyricsMode = "embed"
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
|
||||||
GoLog("[Qobuz] Saving external LRC file...\n")
|
|
||||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
||||||
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
|
||||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
||||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if req.EmbedLyrics {
|
|
||||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to ISRC index for fast duplicate checking
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
|
||||||
|
|
||||||
return QobuzDownloadResult{
|
return QobuzDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: actualBitDepth,
|
BitDepth: actualBitDepth,
|
||||||
@@ -1135,8 +1328,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
Artist: track.Performer.Name,
|
Artist: track.Performer.Name,
|
||||||
Album: track.Album.Title,
|
Album: track.Album.Title,
|
||||||
ReleaseDate: track.Album.ReleaseDate,
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
DiscNumber: req.DiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
|
LyricsLRC: lyricsLRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||||
|
t.Run("reads nested data.url", func(t *testing.T) {
|
||||||
|
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
||||||
|
|
||||||
|
got, err := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if got != "https://example.test/audio.flac" {
|
||||||
|
t.Fatalf("unexpected URL: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reads top-level url", func(t *testing.T) {
|
||||||
|
body := []byte(`{"url":"https://example.test/top.flac"}`)
|
||||||
|
|
||||||
|
got, err := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if got != "https://example.test/top.flac" {
|
||||||
|
t.Fatalf("unexpected URL: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns API error", func(t *testing.T) {
|
||||||
|
body := []byte(`{"error":"track not found"}`)
|
||||||
|
|
||||||
|
_, err := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if err == nil || err.Error() != "track not found" {
|
||||||
|
t.Fatalf("expected track-not-found error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns message when success false", func(t *testing.T) {
|
||||||
|
body := []byte(`{"success":false,"message":"blocked"}`)
|
||||||
|
|
||||||
|
_, err := extractQobuzDownloadURLFromBody(body)
|
||||||
|
if err == nil || err.Error() != "blocked" {
|
||||||
|
t.Fatalf("expected blocked error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||||
|
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||||
|
redacted := sanitizeSensitiveLogText(input)
|
||||||
|
|
||||||
|
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||||
|
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||||
|
}
|
||||||
|
if !strings.Contains(redacted, "[REDACTED]") {
|
||||||
|
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||||
|
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||||
|
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked := []string{
|
||||||
|
"http://accounts.example.com/oauth/authorize",
|
||||||
|
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||||
|
"https://localhost/oauth/authorize",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rawURL := range blocked {
|
||||||
|
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||||
|
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||||
|
ext := &LoadedExtension{
|
||||||
|
ID: "test-ext",
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Name: "test-ext",
|
||||||
|
Permissions: ExtensionPermissions{
|
||||||
|
Network: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||||
|
t.Fatal("expected embedded URL credentials to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||||
|
baseDir := t.TempDir()
|
||||||
|
|
||||||
|
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPathWithinBase(baseDir, destPath) {
|
||||||
|
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := filepath.Base(destPath)
|
||||||
|
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||||
|
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||||
|
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||||
|
t.Fatal("expected empty extension id to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SongLinkClient struct {
|
type SongLinkClient struct {
|
||||||
@@ -16,16 +15,21 @@ type SongLinkClient struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TrackAvailability struct {
|
type TrackAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
Amazon bool `json:"amazon"`
|
Amazon bool `json:"amazon"`
|
||||||
Qobuz bool `json:"qobuz"`
|
Qobuz bool `json:"qobuz"`
|
||||||
Deezer bool `json:"deezer"`
|
Deezer bool `json:"deezer"`
|
||||||
TidalURL string `json:"tidal_url,omitempty"`
|
YouTube bool `json:"youtube"`
|
||||||
AmazonURL string `json:"amazon_url,omitempty"`
|
TidalURL string `json:"tidal_url,omitempty"`
|
||||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
AmazonURL string `json:"amazon_url,omitempty"`
|
||||||
DeezerURL string `json:"deezer_url,omitempty"`
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
DeezerID string `json:"deezer_id,omitempty"`
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
YouTubeURL string `json:"youtube_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
|
QobuzID string `json:"qobuz_id,omitempty"`
|
||||||
|
TidalID string `json:"tidal_id,omitempty"`
|
||||||
|
YouTubeID string `json:"youtube_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -36,17 +40,13 @@ var (
|
|||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
songLinkClientOnce.Do(func() {
|
songLinkClientOnce.Do(func() {
|
||||||
globalSongLinkClient = &SongLinkClient{
|
globalSongLinkClient = &SongLinkClient{
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout),
|
client: NewMetadataHTTPClient(SongLinkTimeout),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalSongLinkClient
|
return globalSongLinkClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
if spotifyTrackID == "" {
|
|
||||||
return nil, fmt.Errorf("spotify track ID is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
@@ -102,6 +102,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
@@ -115,8 +116,26 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isrc != "" {
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = true
|
||||||
|
availability.QobuzURL = qobuzLink.URL
|
||||||
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
||||||
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to regular youtube if youtubeMusic not available
|
||||||
|
if !availability.YouTube {
|
||||||
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
@@ -139,40 +158,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
|
|||||||
return urls, nil
|
return urls, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkQobuzAvailability(isrc string) bool {
|
|
||||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
|
||||||
appID := "798273057"
|
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(client, req)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchResp struct {
|
|
||||||
Tracks struct {
|
|
||||||
Total int `json:"total"`
|
|
||||||
} `json:"tracks"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchResp.Tracks.Total > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||||
func extractDeezerIDFromURL(deezerURL string) string {
|
func extractDeezerIDFromURL(deezerURL string) string {
|
||||||
parts := strings.Split(deezerURL, "/")
|
parts := strings.Split(deezerURL, "/")
|
||||||
@@ -186,19 +171,175 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
||||||
|
// URL formats:
|
||||||
|
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||||
|
// - https://open.qobuz.com/track/12345678
|
||||||
|
// - https://www.qobuz.com/track/12345678
|
||||||
|
// - https://play.qobuz.com/track/12345678
|
||||||
|
func extractQobuzIDFromURL(qobuzURL string) string {
|
||||||
|
if qobuzURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find /track/ID pattern first
|
||||||
|
if strings.Contains(qobuzURL, "/track/") {
|
||||||
|
parts := strings.Split(qobuzURL, "/track/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
idPart := parts[1]
|
||||||
|
// Remove query parameters
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
// Remove trailing slash or path
|
||||||
|
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
// Validate it's a number
|
||||||
|
if idPart != "" && isNumeric(idPart) {
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from album URL with track highlight
|
||||||
|
// Format: /album/albumname/trackid or ?trackId=12345678
|
||||||
|
if strings.Contains(qobuzURL, "trackId=") {
|
||||||
|
parts := strings.Split(qobuzURL, "trackId=")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
if idPart != "" && isNumeric(idPart) {
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: get last numeric segment from URL
|
||||||
|
parts := strings.Split(qobuzURL, "/")
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
part := parts[i]
|
||||||
|
// Remove query parameters
|
||||||
|
if idx := strings.Index(part, "?"); idx > 0 {
|
||||||
|
part = part[:idx]
|
||||||
|
}
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" && isNumeric(part) {
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTidalIDFromURL extracts Tidal track ID from URL
|
||||||
|
// URL formats:
|
||||||
|
// - https://tidal.com/browse/track/12345678
|
||||||
|
// - https://listen.tidal.com/track/12345678
|
||||||
|
func extractTidalIDFromURL(tidalURL string) string {
|
||||||
|
if tidalURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(tidalURL, "/track/") {
|
||||||
|
parts := strings.Split(tidalURL, "/track/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
if idPart != "" && isNumeric(idPart) {
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractYouTubeIDFromURL extracts YouTube video ID from URL
|
||||||
|
// URL formats:
|
||||||
|
// - https://www.youtube.com/watch?v=VIDEO_ID
|
||||||
|
// - https://youtu.be/VIDEO_ID
|
||||||
|
// - https://music.youtube.com/watch?v=VIDEO_ID
|
||||||
|
func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||||
|
if youtubeURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle youtu.be short URLs
|
||||||
|
if strings.Contains(youtubeURL, "youtu.be/") {
|
||||||
|
parts := strings.Split(youtubeURL, "youtu.be/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(idPart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle youtube.com URLs with ?v= parameter
|
||||||
|
parsed, err := url.Parse(youtubeURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := parsed.Query().Get("v"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle /embed/ format
|
||||||
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return strings.Split(parts[1], "/")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNumeric is defined in library_scan.go
|
||||||
|
|
||||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Deezer || availability.DeezerID == "" {
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
return "", fmt.Errorf("track not found on Deezer")
|
return "", fmt.Errorf("track not found on Deezer")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.YouTube || availability.YouTubeURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on YouTube")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.YouTubeURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AlbumAvailability represents album availability on different platforms
|
// AlbumAvailability represents album availability on different platforms
|
||||||
type AlbumAvailability struct {
|
type AlbumAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
@@ -208,10 +349,8 @@ type AlbumAvailability struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
// Use global rate limiter
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL for album
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||||
|
|
||||||
@@ -268,11 +407,11 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Deezer || availability.DeezerID == "" {
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
return "", fmt.Errorf("album not found on Deezer")
|
return "", fmt.Errorf("album not found on Deezer")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +420,23 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
if deezerTrackID == "" {
|
if deezerTrackID == "" {
|
||||||
return nil, fmt.Errorf("deezer track ID is empty")
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
|
||||||
|
idhsClient := NewIDHSClient()
|
||||||
|
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
||||||
|
}
|
||||||
|
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
||||||
|
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
@@ -301,7 +456,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||||
}
|
}
|
||||||
@@ -348,6 +502,7 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
@@ -355,10 +510,29 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
availability.AmazonURL = amazonLink.URL
|
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 != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.DeezerURL = 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,12 +543,9 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL using platform, type, and id parameters (as per API docs)
|
|
||||||
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
|
||||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||||
url.QueryEscape(platform),
|
url.QueryEscape(platform),
|
||||||
url.QueryEscape(entityType),
|
url.QueryEscape(entityType),
|
||||||
@@ -392,7 +563,6 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||||
}
|
}
|
||||||
@@ -430,6 +600,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
@@ -437,12 +608,31 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
availability.AmazonURL = amazonLink.URL
|
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 != "" {
|
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 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,11 +654,11 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if availability.SpotifyID == "" {
|
if availability.SpotifyID == "" {
|
||||||
return "", fmt.Errorf("track not found on Spotify")
|
return "", fmt.Errorf("track not found on Spotify")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.SpotifyID, nil
|
return availability.SpotifyID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,11 +668,11 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Tidal || availability.TidalURL == "" {
|
if !availability.Tidal || availability.TidalURL == "" {
|
||||||
return "", fmt.Errorf("track not found on Tidal")
|
return "", fmt.Errorf("track not found on Tidal")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.TidalURL, nil
|
return availability.TidalURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,10 +681,108 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Amazon || availability.AmazonURL == "" {
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
return "", fmt.Errorf("track not found on Amazon Music")
|
return "", fmt.Errorf("track not found on Amazon Music")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.AmazonURL, nil
|
return availability.AmazonURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.YouTube || availability.YouTubeURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on YouTube")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.YouTubeURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
EntityID string `json:"entityUniqueId"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
|
}
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
|
availability.Qobuz = true
|
||||||
|
availability.QobuzURL = qobuzLink.URL
|
||||||
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
|
}
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
|
}
|
||||||
|
if !availability.YouTube {
|
||||||
|
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||||
|
|
||||||
|
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||||
|
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||||
|
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
||||||
|
parsed, err := parseSpotifyURI(spotifyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimSpace(apiBaseURL)
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultSpotFetchAPIBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsed.Type {
|
||||||
|
case "track":
|
||||||
|
var trackResp TrackResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||||
|
}
|
||||||
|
return trackResp, nil
|
||||||
|
case "album":
|
||||||
|
var albumResp AlbumResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||||
|
}
|
||||||
|
return &albumResp, nil
|
||||||
|
case "playlist":
|
||||||
|
var playlistResp PlaylistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||||
|
}
|
||||||
|
return playlistResp, nil
|
||||||
|
case "artist":
|
||||||
|
var artistResp ArtistResponsePayload
|
||||||
|
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||||
|
}
|
||||||
|
return &artistResp, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,10 +63,8 @@ var (
|
|||||||
credentialsMu sync.RWMutex
|
credentialsMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
|
||||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||||
|
|
||||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
|
||||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
credentialsMu.Lock()
|
credentialsMu.Lock()
|
||||||
defer credentialsMu.Unlock()
|
defer credentialsMu.Unlock()
|
||||||
@@ -89,7 +87,6 @@ func HasSpotifyCredentials() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCredentials returns the current credentials or error if not configured
|
|
||||||
func getCredentials() (string, string, error) {
|
func getCredentials() (string, string, error) {
|
||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
@@ -117,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
|||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
@@ -143,7 +140,7 @@ type TrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumTrackMetadata struct {
|
type AlbumTrackMetadata struct {
|
||||||
@@ -170,6 +167,7 @@ type AlbumInfoMetadata struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
|
ArtistId string `json:"artist_id,omitempty"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
@@ -211,7 +209,7 @@ type ArtistAlbumMetadata struct {
|
|||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
AlbumType string `json:"album_type"` // album, single, compilation
|
AlbumType string `json:"album_type"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,9 +235,29 @@ type SearchArtistResult struct {
|
|||||||
Popularity int `json:"popularity"`
|
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 {
|
type SearchAllResult struct {
|
||||||
Tracks []TrackMetadata `json:"tracks"`
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
Artists []SearchArtistResult `json:"artists"`
|
Artists []SearchArtistResult `json:"artists"`
|
||||||
|
Albums []SearchAlbumResult `json:"albums"`
|
||||||
|
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type spotifyURI struct {
|
type spotifyURI struct {
|
||||||
@@ -512,11 +530,18 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
albumImage := firstImageURL(data.Images)
|
albumImage := firstImageURL(data.Images)
|
||||||
|
|
||||||
|
var firstArtistId string
|
||||||
|
if len(data.Artists) > 0 {
|
||||||
|
firstArtistId = data.Artists[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: data.TotalTracks,
|
TotalTracks: data.TotalTracks,
|
||||||
Name: data.Name,
|
Name: data.Name,
|
||||||
ReleaseDate: data.ReleaseDate,
|
ReleaseDate: data.ReleaseDate,
|
||||||
Artists: joinArtists(data.Artists),
|
Artists: joinArtists(data.Artists),
|
||||||
|
ArtistId: firstArtistId,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,7 +563,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
|
|
||||||
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||||
|
|
||||||
// Collect track IDs for parallel ISRC fetching
|
|
||||||
trackIDs := make([]string, len(allTrackItems))
|
trackIDs := make([]string, len(allTrackItems))
|
||||||
for i, item := range allTrackItems {
|
for i, item := range allTrackItems {
|
||||||
trackIDs[i] = item.ID
|
trackIDs[i] = item.ID
|
||||||
@@ -910,14 +934,14 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
macMajor := c.rng.Intn(4) + 11
|
macMajor := c.rng.Intn(4) + 11
|
||||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
macMinor := c.rng.Intn(5) + 4
|
||||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
webkitMajor := c.rng.Intn(7) + 530
|
||||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
webkitMinor := c.rng.Intn(7) + 30
|
||||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
chromeMajor := c.rng.Intn(25) + 80
|
||||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
chromeBuild := c.rng.Intn(1500) + 3000
|
||||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
chromePatch := c.rng.Intn(65) + 60
|
||||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
safariMajor := c.rng.Intn(7) + 530
|
||||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
safariMinor := c.rng.Intn(6) + 30
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||||
|
|||||||
@@ -119,17 +119,18 @@ func NewTidalDownloader() *TidalDownloader {
|
|||||||
return globalTidalDownloader
|
return globalTidalDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available Tidal APIs
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
"dGlkYWwua2lub3BsdXMub25saW5l",
|
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org (priority)
|
||||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
|
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online
|
||||||
"dHJpdG9uLnNxdWlkLnd0Zg==",
|
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
||||||
"dm9nZWwucXFkbC5zaXRl",
|
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
||||||
"bWF1cy5xcWRsLnNpdGU=",
|
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
||||||
"aHVuZC5xcWRsLnNpdGU=",
|
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
||||||
"a2F0emUucXFkbC5zaXRl",
|
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
||||||
"d29sZi5xcWRsLnNpdGU=",
|
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
||||||
|
"aGlmaS1vbmUuc3BvdGlzYXZlci5uZXQ=", // hifi-one.spotisaver.net
|
||||||
|
"aGlmaS10d28uc3BvdGlzYXZlci5uZXQ=", // hifi-two.spotisaver.net
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -249,7 +250,6 @@ func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
|||||||
return trackID, nil
|
return trackID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrackInfoByID gets track info by Tidal track ID
|
|
||||||
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -317,7 +317,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find exact ISRC match
|
|
||||||
for i := range result.Items {
|
for i := range result.Items {
|
||||||
if result.Items[i].ISRC == isrc {
|
if result.Items[i].ISRC == isrc {
|
||||||
return &result.Items[i], nil
|
return &result.Items[i], nil
|
||||||
@@ -331,7 +330,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
@@ -342,7 +340,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
// Build search queries - multiple strategies (same as PC version)
|
// Build search queries - multiple strategies (same as PC version)
|
||||||
queries := []string{}
|
queries := []string{}
|
||||||
|
|
||||||
// Strategy 1: Artist + Track name (original)
|
|
||||||
if artistName != "" && trackName != "" {
|
if artistName != "" && trackName != "" {
|
||||||
queries = append(queries, artistName+" "+trackName)
|
queries = append(queries, artistName+" "+trackName)
|
||||||
}
|
}
|
||||||
@@ -443,13 +440,13 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
if durationDiff <= 3 {
|
if durationDiff <= 3 {
|
||||||
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
GoLog("[Tidal] ISRC match: '%s' (duration verified)\n", track.Title)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||||
expectedDuration, track.Duration)
|
expectedDuration, track.Duration)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
|
GoLog("[Tidal] ISRC match: '%s'\n", track.Title)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,7 +485,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(durationVerifiedMatches) > 0 {
|
if len(durationVerifiedMatches) > 0 {
|
||||||
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
GoLog("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||||
return durationVerifiedMatches[0], nil
|
return durationVerifiedMatches[0], nil
|
||||||
}
|
}
|
||||||
@@ -499,11 +496,11 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
expectedDuration, isrcMatches[0].Duration)
|
expectedDuration, isrcMatches[0].Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
GoLog("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
return isrcMatches[0], nil
|
return isrcMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
GoLog("[Tidal] No ISRC match found for: %s\n", spotifyISRC)
|
||||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,13 +582,123 @@ type tidalAPIResult struct {
|
|||||||
duration time.Duration
|
duration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
// Tidal API timeout configuration
|
||||||
|
// Mobile networks are more unstable, so we use longer timeouts
|
||||||
|
const (
|
||||||
|
tidalAPITimeoutMobile = 25 * time.Second
|
||||||
|
tidalMaxRetries = 2 // Number of retries per API
|
||||||
|
tidalRetryDelay = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic
|
||||||
|
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
|
||||||
|
var lastErr error
|
||||||
|
retryDelay := tidalRetryDelay
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= tidalMaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
retryDelay *= 2 // Exponential backoff
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewHTTPClientWithTimeout(timeout)
|
||||||
|
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
// Check for retryable errors (timeout, connection reset)
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
if strings.Contains(errStr, "timeout") ||
|
||||||
|
strings.Contains(errStr, "reset") ||
|
||||||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
strings.Contains(errStr, "eof") {
|
||||||
|
continue // Retry
|
||||||
|
}
|
||||||
|
break // Non-retryable error
|
||||||
|
}
|
||||||
|
// Server errors are retryable
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 429 rate limit - wait and retry
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
lastErr = fmt.Errorf("rate limited")
|
||||||
|
retryDelay = 2 * time.Second // Wait longer for rate limit
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return TidalDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try V2 response format (with manifest)
|
||||||
|
var v2Response TidalAPIResponseV2
|
||||||
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
|
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||||
|
return TidalDownloadInfo{}, fmt.Errorf("returned PREVIEW instead of FULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
return TidalDownloadInfo{
|
||||||
|
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||||
|
BitDepth: v2Response.Data.BitDepth,
|
||||||
|
SampleRate: v2Response.Data.SampleRate,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try V1 response format
|
||||||
|
var v1Responses []struct {
|
||||||
|
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||||
|
for _, item := range v1Responses {
|
||||||
|
if item.OriginalTrackURL != "" {
|
||||||
|
return TidalDownloadInfo{
|
||||||
|
URL: item.OriginalTrackURL,
|
||||||
|
BitDepth: 16,
|
||||||
|
SampleRate: 44100,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TidalDownloadInfo{}, fmt.Errorf("no download URL or manifest in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return TidalDownloadInfo{}, lastErr
|
||||||
|
}
|
||||||
|
return TidalDownloadInfo{}, fmt.Errorf("all retries failed")
|
||||||
|
}
|
||||||
|
|
||||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
GoLog("[Tidal] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
|
||||||
|
|
||||||
resultChan := make(chan tidalAPIResult, len(apis))
|
resultChan := make(chan tidalAPIResult, len(apis))
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -599,69 +706,13 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
for _, apiURL := range apis {
|
for _, apiURL := range apis {
|
||||||
go func(api string) {
|
go func(api string) {
|
||||||
reqStart := time.Now()
|
reqStart := time.Now()
|
||||||
|
info, err := fetchTidalURLWithRetry(api, trackID, quality, tidalAPITimeoutMobile)
|
||||||
client := NewHTTPClientWithTimeout(15 * time.Second)
|
resultChan <- tidalAPIResult{
|
||||||
|
apiURL: api,
|
||||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
info: info,
|
||||||
|
err: err,
|
||||||
req, err := http.NewRequest("GET", reqURL, nil)
|
duration: time.Since(reqStart),
|
||||||
if err != nil {
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var v2Response TidalAPIResponseV2
|
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
|
||||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
info := TidalDownloadInfo{
|
|
||||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
|
||||||
BitDepth: v2Response.Data.BitDepth,
|
|
||||||
SampleRate: v2Response.Data.SampleRate,
|
|
||||||
}
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var v1Responses []struct {
|
|
||||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
|
||||||
for _, item := range v1Responses {
|
|
||||||
if item.OriginalTrackURL != "" {
|
|
||||||
info := TidalDownloadInfo{
|
|
||||||
URL: item.OriginalTrackURL,
|
|
||||||
BitDepth: 16,
|
|
||||||
SampleRate: 44100,
|
|
||||||
}
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
|
|
||||||
}(apiURL)
|
}(apiURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +721,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
for i := 0; i < len(apis); i++ {
|
for i := 0; i < len(apis); i++ {
|
||||||
result := <-resultChan
|
result := <-resultChan
|
||||||
if result.err == nil {
|
if result.err == nil {
|
||||||
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
GoLog("[Tidal] [Parallel] Got response from %s (%d-bit/%dHz) in %v\n",
|
||||||
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
||||||
|
|
||||||
go func(remaining int) {
|
go func(remaining int) {
|
||||||
@@ -788,6 +839,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if segmentCount == 0 {
|
||||||
|
return "", "", nil, fmt.Errorf("no segments found in manifest")
|
||||||
|
}
|
||||||
|
|
||||||
for i := 1; i <= segmentCount; i++ {
|
for i := 1; i <= segmentCount; i++ {
|
||||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
@@ -796,8 +851,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
return "", initURL, mediaURLs, nil
|
return "", initURL, mediaURLs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with progress tracking
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||||
@@ -810,7 +864,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, outputFD, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
@@ -847,7 +901,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := openOutputForWrite(outputPath, outputFD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -866,30 +920,30 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
|
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath string, outputFD int, itemID string) error {
|
||||||
fmt.Println("[Tidal] Parsing manifest...")
|
fmt.Println("[Tidal] Parsing manifest...")
|
||||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -903,7 +957,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
|
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,7 +988,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := openOutputForWrite(outputPath, outputFD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -950,29 +1004,40 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
// For DASH format, determine correct M4A path
|
||||||
|
// If outputPath already ends with .m4a, use it directly.
|
||||||
|
// If outputPath ends with .flac, convert .flac to .m4a.
|
||||||
|
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
|
||||||
|
var m4aPath string
|
||||||
|
if strings.HasSuffix(outputPath, ".m4a") {
|
||||||
|
m4aPath = outputPath
|
||||||
|
} else if strings.HasSuffix(outputPath, ".flac") {
|
||||||
|
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||||
|
} else {
|
||||||
|
m4aPath = outputPath
|
||||||
|
}
|
||||||
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||||
|
|
||||||
out, err := os.Create(m4aPath)
|
out, err := openOutputForWrite(m4aPath, outputFD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
||||||
return fmt.Errorf("failed to create M4A file: %w", err)
|
return fmt.Errorf("failed to create M4A file: %w", err)
|
||||||
@@ -981,20 +1046,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
GoLog("[Tidal] Downloading init segment...\n")
|
GoLog("[Tidal] Downloading init segment...\n")
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
GoLog("[Tidal] Init segment request failed: %v\n", err)
|
GoLog("[Tidal] Init segment request failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to create init segment request: %w", err)
|
return fmt.Errorf("failed to create init segment request: %w", err)
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
@@ -1004,7 +1069,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
||||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -1012,7 +1077,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
@@ -1024,7 +1089,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
for i, mediaURL := range mediaURLs {
|
for i, mediaURL := range mediaURLs {
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,14 +1105,14 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
|
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
|
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
@@ -1057,7 +1122,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
||||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -1065,7 +1130,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
return ErrDownloadCancelled
|
return ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
@@ -1075,7 +1140,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := out.Close(); err != nil {
|
if err := out.Close(); err != nil {
|
||||||
os.Remove(m4aPath)
|
cleanupOutputOnError(m4aPath, outputFD)
|
||||||
GoLog("[Tidal] Failed to close M4A file: %v\n", err)
|
GoLog("[Tidal] Failed to close M4A file: %v\n", err)
|
||||||
return fmt.Errorf("failed to close M4A file: %w", err)
|
return fmt.Errorf("failed to close M4A file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1095,6 +1160,7 @@ type TidalDownloadResult struct {
|
|||||||
TrackNumber int
|
TrackNumber int
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
ISRC string
|
ISRC string
|
||||||
|
LyricsLRC string // LRC content for embedding in converted files
|
||||||
}
|
}
|
||||||
|
|
||||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||||
@@ -1105,7 +1171,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
|
||||||
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1164,7 +1229,6 @@ func sameWordsUnordered(a, b string) bool {
|
|||||||
wordsA := strings.Fields(a)
|
wordsA := strings.Fields(a)
|
||||||
wordsB := strings.Fields(b)
|
wordsB := strings.Fields(b)
|
||||||
|
|
||||||
// Must have same number of words
|
|
||||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1197,7 +1261,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -1206,7 +1269,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean both titles and compare
|
|
||||||
cleanExpected := cleanTitle(normExpected)
|
cleanExpected := cleanTitle(normExpected)
|
||||||
cleanFound := cleanTitle(normFound)
|
cleanFound := cleanTitle(normFound)
|
||||||
|
|
||||||
@@ -1220,7 +1282,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract core title (before any parentheses/brackets)
|
|
||||||
coreExpected := extractCoreTitle(normExpected)
|
coreExpected := extractCoreTitle(normExpected)
|
||||||
coreFound := extractCoreTitle(normFound)
|
coreFound := extractCoreTitle(normFound)
|
||||||
|
|
||||||
@@ -1228,7 +1289,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
|
||||||
expectedLatin := isLatinScript(expectedTitle)
|
expectedLatin := isLatinScript(expectedTitle)
|
||||||
foundLatin := isLatinScript(foundTitle)
|
foundLatin := isLatinScript(foundTitle)
|
||||||
if expectedLatin != foundLatin {
|
if expectedLatin != foundLatin {
|
||||||
@@ -1346,12 +1406,14 @@ func isLatinScript(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
if !isSafOutput {
|
||||||
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
|
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedDurationSec := req.DurationMS / 1000
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
@@ -1407,49 +1469,83 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
|
|
||||||
if track == nil && req.SpotifyID != "" {
|
if track == nil && req.SpotifyID != "" {
|
||||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||||
var tidalURL string
|
|
||||||
var slErr error
|
var trackID int64
|
||||||
|
var gotTidalID bool
|
||||||
|
|
||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID)
|
availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
|
if slErr == nil && availability != nil && availability.TidalID != "" {
|
||||||
|
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||||
|
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
|
||||||
|
gotTidalID = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to URL parsing if TidalID not in struct
|
||||||
|
if !gotTidalID && availability != nil && availability.TidalURL != "" {
|
||||||
|
var idErr error
|
||||||
|
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
||||||
|
if idErr == nil && trackID > 0 {
|
||||||
|
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
|
||||||
|
gotTidalID = true
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
songlink := NewSongLinkClient()
|
||||||
|
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
|
if slErr == nil && availability != nil && availability.TidalID != "" {
|
||||||
|
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||||
|
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
|
||||||
|
gotTidalID = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to URL parsing if TidalID not in struct
|
||||||
|
if !gotTidalID && availability != nil && availability.TidalURL != "" {
|
||||||
|
var idErr error
|
||||||
|
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
|
||||||
|
if idErr == nil && trackID > 0 {
|
||||||
|
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
|
||||||
|
gotTidalID = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if slErr == nil && tidalURL != "" {
|
if gotTidalID && trackID > 0 {
|
||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
track, err = downloader.GetTrackInfoByID(trackID)
|
||||||
if idErr == nil {
|
if track != nil {
|
||||||
track, err = downloader.GetTrackInfoByID(trackID)
|
tidalArtist := track.Artist.Name
|
||||||
if track != nil {
|
if len(track.Artists) > 0 {
|
||||||
tidalArtist := track.Artist.Name
|
var artistNames []string
|
||||||
if len(track.Artists) > 0 {
|
for _, a := range track.Artists {
|
||||||
var artistNames []string
|
artistNames = append(artistNames, a.Name)
|
||||||
for _, a := range track.Artists {
|
|
||||||
artistNames = append(artistNames, a.Name)
|
|
||||||
}
|
|
||||||
tidalArtist = strings.Join(artistNames, ", ")
|
|
||||||
}
|
}
|
||||||
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, tidalArtist)
|
req.ArtistName, tidalArtist)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if track != nil && expectedDurationSec > 0 {
|
if track != nil && expectedDurationSec > 0 {
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
|
||||||
if durationDiff > 3 {
|
|
||||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
|
||||||
expectedDurationSec, track.Duration)
|
|
||||||
track = nil // Reject this match
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if durationDiff > 3 {
|
||||||
|
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||||
|
expectedDurationSec, track.Duration)
|
||||||
|
track = nil // Reject this match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for future use
|
||||||
|
if track != nil && req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1502,6 +1598,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
quality := req.Quality
|
||||||
|
if quality == "" {
|
||||||
|
quality = "LOSSLESS"
|
||||||
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -1510,27 +1611,55 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
"year": extractYear(req.ReleaseDate),
|
"year": extractYear(req.ReleaseDate),
|
||||||
"disc": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
})
|
})
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
|
||||||
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
outputExt := strings.TrimSpace(req.OutputExt)
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
if outputExt == "" {
|
||||||
}
|
if quality == "HIGH" {
|
||||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
outputExt = ".m4a"
|
||||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
} else {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
outputExt = ".flac"
|
||||||
|
}
|
||||||
|
} else if !strings.HasPrefix(outputExt, ".") {
|
||||||
|
outputExt = "." + outputExt
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := outputPath + ".m4a.tmp"
|
var outputPath string
|
||||||
if _, err := os.Stat(tmpPath); err == nil {
|
var m4aPath string
|
||||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
if isSafOutput {
|
||||||
os.Remove(tmpPath)
|
outputPath = strings.TrimSpace(req.OutputPath)
|
||||||
|
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||||
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
|
}
|
||||||
|
m4aPath = outputPath
|
||||||
|
} else {
|
||||||
|
if outputExt == ".m4a" || quality == "HIGH" {
|
||||||
|
filename = sanitizeFilename(filename) + ".m4a"
|
||||||
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
|
m4aPath = outputPath
|
||||||
|
} else {
|
||||||
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
|
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
|
}
|
||||||
|
if quality != "HIGH" {
|
||||||
|
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
|
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
quality := req.Quality
|
if !isSafOutput {
|
||||||
if quality == "" {
|
tmpPath := outputPath + ".m4a.tmp"
|
||||||
quality = "LOSSLESS"
|
if _, err := os.Stat(tmpPath); err == nil {
|
||||||
|
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||||
|
|
||||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
@@ -1563,7 +1692,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
return "Direct URL"
|
return "Direct URL"
|
||||||
}())
|
}())
|
||||||
|
|
||||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||||
if errors.Is(err, ErrDownloadCancelled) {
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
return TidalDownloadResult{}, ErrDownloadCancelled
|
return TidalDownloadResult{}, ErrDownloadCancelled
|
||||||
}
|
}
|
||||||
@@ -1580,11 +1709,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actualOutputPath := outputPath
|
actualOutputPath := outputPath
|
||||||
if _, err := os.Stat(m4aPath); err == nil {
|
if !isSafOutput {
|
||||||
actualOutputPath = m4aPath
|
if _, err := os.Stat(m4aPath); err == nil {
|
||||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
actualOutputPath = m4aPath
|
||||||
} else if _, err := os.Stat(outputPath); err != nil {
|
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
} else if _, err := os.Stat(outputPath); err != nil {
|
||||||
|
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseDate := req.ReleaseDate
|
releaseDate := req.ReleaseDate
|
||||||
@@ -1593,15 +1724,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
|
GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actualTrackNumber := req.TrackNumber
|
||||||
|
actualDiscNumber := req.DiscNumber
|
||||||
|
if actualTrackNumber == 0 {
|
||||||
|
actualTrackNumber = track.TrackNumber
|
||||||
|
}
|
||||||
|
if actualDiscNumber == 0 {
|
||||||
|
actualDiscNumber = track.VolumeNumber
|
||||||
|
}
|
||||||
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: releaseDate,
|
Date: releaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: track.VolumeNumber,
|
DiscNumber: actualDiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
@@ -1614,7 +1754,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(actualOutputPath, ".flac") {
|
actualExt := outputExt
|
||||||
|
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
||||||
|
actualExt = ".m4a"
|
||||||
|
}
|
||||||
|
if actualExt == "" && !isSafOutput {
|
||||||
|
actualExt = strings.ToLower(filepath.Ext(actualOutputPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) {
|
||||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -1625,7 +1773,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
lyricsMode = "embed"
|
lyricsMode = "embed"
|
||||||
}
|
}
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||||
GoLog("[Tidal] Saving external LRC file...\n")
|
GoLog("[Tidal] Saving external LRC file...\n")
|
||||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
@@ -1645,22 +1793,93 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
} else if req.EmbedLyrics {
|
} else if req.EmbedLyrics {
|
||||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
||||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
if quality == "HIGH" {
|
||||||
|
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
||||||
|
|
||||||
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsMode := req.LyricsMode
|
||||||
|
if lyricsMode == "" {
|
||||||
|
lyricsMode = "embed"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||||
|
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
||||||
|
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
|
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
if !isSafOutput {
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
bitDepth := downloadInfo.BitDepth
|
||||||
|
sampleRate := downloadInfo.SampleRate
|
||||||
|
lyricsLRC := ""
|
||||||
|
if quality == "HIGH" {
|
||||||
|
bitDepth = 0
|
||||||
|
sampleRate = 44100
|
||||||
|
}
|
||||||
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
|
}
|
||||||
|
|
||||||
return TidalDownloadResult{
|
return TidalDownloadResult{
|
||||||
FilePath: actualOutputPath,
|
FilePath: actualOutputPath,
|
||||||
BitDepth: downloadInfo.BitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: downloadInfo.SampleRate,
|
SampleRate: sampleRate,
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
Artist: track.Artist.Name,
|
Artist: track.Artist.Name,
|
||||||
Album: track.Album.Title,
|
Album: track.Album.Title,
|
||||||
ReleaseDate: track.Album.ReleaseDate,
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
TrackNumber: track.TrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
DiscNumber: track.VolumeNumber,
|
DiscNumber: actualDiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
|
LyricsLRC: lyricsLRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseTidalURL(input string) (string, string, error) {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", "", fmt.Errorf("empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Host != "tidal.com" && parsed.Host != "listen.tidal.com" && parsed.Host != "www.tidal.com" {
|
||||||
|
return "", "", fmt.Errorf("not a Tidal URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
|
||||||
|
// Handle /browse/track/123 format
|
||||||
|
if len(parts) > 0 && parts[0] == "browse" {
|
||||||
|
parts = parts[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", "", fmt.Errorf("invalid Tidal URL format")
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceType := parts[0]
|
||||||
|
resourceID := parts[1]
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track", "album", "artist", "playlist":
|
||||||
|
return resourceType, resourceID, nil
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type YouTubeDownloader struct {
|
||||||
|
client *http.Client
|
||||||
|
apiURL string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalYouTubeDownloader *YouTubeDownloader
|
||||||
|
youtubeDownloaderOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
type YouTubeQuality string
|
||||||
|
|
||||||
|
const (
|
||||||
|
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||||
|
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CobaltRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||||
|
AudioFormat string `json:"audioFormat,omitempty"`
|
||||||
|
DownloadMode string `json:"downloadMode,omitempty"`
|
||||||
|
FilenameStyle string `json:"filenameStyle,omitempty"`
|
||||||
|
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CobaltResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Error *struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Context *struct {
|
||||||
|
Service string `json:"service,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
} `json:"context,omitempty"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YouTubeDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
|
Format string // "opus" or "mp3"
|
||||||
|
Bitrate int
|
||||||
|
LyricsLRC string
|
||||||
|
CoverData []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||||
|
youtubeDownloaderOnce.Do(func() {
|
||||||
|
globalYouTubeDownloader = &YouTubeDownloader{
|
||||||
|
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||||
|
apiURL: "https://api.qwkuns.me",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalYouTubeDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchYouTube returns a YouTube Music search URL for the given track
|
||||||
|
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||||
|
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||||
|
searchQuery := url.QueryEscape(query)
|
||||||
|
|
||||||
|
GoLog("[YouTube] Search query: %s\n", query)
|
||||||
|
|
||||||
|
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
||||||
|
|
||||||
|
return youtubeMusicURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
||||||
|
y.mu.Lock()
|
||||||
|
defer y.mu.Unlock()
|
||||||
|
|
||||||
|
var audioFormat string
|
||||||
|
var audioBitrate string
|
||||||
|
|
||||||
|
switch quality {
|
||||||
|
case YouTubeQualityOpus256:
|
||||||
|
audioFormat = "opus"
|
||||||
|
audioBitrate = "256"
|
||||||
|
case YouTubeQualityMP3320:
|
||||||
|
audioFormat = "mp3"
|
||||||
|
audioBitrate = "320"
|
||||||
|
default:
|
||||||
|
audioFormat = "mp3"
|
||||||
|
audioBitrate = "320"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try SpotubeDL first (primary)
|
||||||
|
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||||
|
if extractErr == nil {
|
||||||
|
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||||
|
videoID, audioFormat, audioBitrate)
|
||||||
|
|
||||||
|
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||||
|
} else {
|
||||||
|
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: direct Cobalt API (api.qwkuns.me)
|
||||||
|
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
||||||
|
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
||||||
|
cobaltURL, audioFormat, audioBitrate)
|
||||||
|
|
||||||
|
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestCobaltDirect sends a download request to the primary Cobalt API.
|
||||||
|
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||||
|
reqBody := CobaltRequest{
|
||||||
|
URL: videoURL,
|
||||||
|
AudioFormat: audioFormat,
|
||||||
|
AudioBitrate: audioBitrate,
|
||||||
|
DownloadMode: "audio",
|
||||||
|
FilenameStyle: "basic",
|
||||||
|
DisableMetadata: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cobaltResp CobaltResponse
|
||||||
|
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
||||||
|
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
||||||
|
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cobaltResp.URL == "" {
|
||||||
|
return nil, fmt.Errorf("no download URL in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
||||||
|
return &cobaltResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||||
|
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
|
||||||
|
videoID, audioFormat, audioBitrate)
|
||||||
|
|
||||||
|
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.URL == "" {
|
||||||
|
return nil, fmt.Errorf("no download URL from spotubedl")
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Got download URL from SpotubeDL\n")
|
||||||
|
return &CobaltResponse{
|
||||||
|
Status: "tunnel",
|
||||||
|
URL: result.URL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if itemID != "" {
|
||||||
|
StartItemProgress(itemID)
|
||||||
|
defer CompleteItemProgress(itemID)
|
||||||
|
ctx = initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||||
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
|
if expectedSize > 0 && itemID != "" {
|
||||||
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := openOutputForWrite(outputPath, outputFD)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
|
var written int64
|
||||||
|
if itemID != "" {
|
||||||
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
|
} else {
|
||||||
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
||||||
|
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
||||||
|
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildYouTubeWatchURL(videoID string) string {
|
||||||
|
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
|
||||||
|
func isYouTubeVideoID(s string) bool {
|
||||||
|
if len(s) != 11 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsYouTubeURL(urlStr string) bool {
|
||||||
|
lower := strings.ToLower(urlStr)
|
||||||
|
return strings.Contains(lower, "youtube.com") ||
|
||||||
|
strings.Contains(lower, "youtu.be") ||
|
||||||
|
strings.Contains(lower, "music.youtube.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
||||||
|
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
||||||
|
func toYouTubeMusicURL(rawURL string) string {
|
||||||
|
videoID, err := ExtractYouTubeVideoID(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||||
|
if strings.Contains(urlStr, "youtu.be/") {
|
||||||
|
parts := strings.Split(urlStr, "youtu.be/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
videoID := strings.Split(parts[1], "?")[0]
|
||||||
|
videoID = strings.Split(videoID, "&")[0]
|
||||||
|
return strings.TrimSpace(videoID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /watch?v=
|
||||||
|
if v := parsed.Query().Get("v"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /embed/
|
||||||
|
if strings.Contains(parsed.Path, "/embed/") {
|
||||||
|
parts := strings.Split(parsed.Path, "/embed/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return strings.Split(parts[1], "/")[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /v/
|
||||||
|
if strings.Contains(parsed.Path, "/v/") {
|
||||||
|
parts := strings.Split(parsed.Path, "/v/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return strings.Split(parts[1], "/")[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not extract video ID from URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||||
|
downloader := NewYouTubeDownloader()
|
||||||
|
|
||||||
|
var quality YouTubeQuality
|
||||||
|
switch strings.ToLower(req.Quality) {
|
||||||
|
case "opus_256", "opus256", "opus":
|
||||||
|
quality = YouTubeQualityOpus256
|
||||||
|
case "mp3_320", "mp3320", "mp3":
|
||||||
|
quality = YouTubeQualityMP3320
|
||||||
|
default:
|
||||||
|
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
||||||
|
var youtubeURL string
|
||||||
|
var lookupErr error
|
||||||
|
|
||||||
|
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
||||||
|
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
||||||
|
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
||||||
|
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Spotify ID via SongLink
|
||||||
|
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
||||||
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
||||||
|
if lookupErr != nil {
|
||||||
|
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Deezer ID via SongLink
|
||||||
|
if youtubeURL == "" && req.DeezerID != "" {
|
||||||
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
||||||
|
if lookupErr != nil {
|
||||||
|
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try ISRC via SongLink
|
||||||
|
if youtubeURL == "" && req.ISRC != "" {
|
||||||
|
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
||||||
|
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
||||||
|
youtubeURL = availability.YouTubeURL
|
||||||
|
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
||||||
|
} else if isrcErr != nil {
|
||||||
|
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cobalt requires direct video URLs, not search URLs
|
||||||
|
if youtubeURL == "" {
|
||||||
|
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
||||||
|
|
||||||
|
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
||||||
|
if err != nil {
|
||||||
|
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext string
|
||||||
|
var format string
|
||||||
|
var bitrate int
|
||||||
|
switch quality {
|
||||||
|
case YouTubeQualityOpus256:
|
||||||
|
ext = ".opus"
|
||||||
|
format = "opus"
|
||||||
|
bitrate = 256
|
||||||
|
case YouTubeQualityMP3320:
|
||||||
|
ext = ".mp3"
|
||||||
|
format = "mp3"
|
||||||
|
bitrate = 320
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||||
|
"title": req.TrackName,
|
||||||
|
"artist": req.ArtistName,
|
||||||
|
"album": req.AlbumName,
|
||||||
|
"track": req.TrackNumber,
|
||||||
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"disc": req.DiscNumber,
|
||||||
|
})
|
||||||
|
filename = sanitizeFilename(filename) + ext
|
||||||
|
|
||||||
|
var outputPath string
|
||||||
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||||
|
if isSafOutput {
|
||||||
|
outputPath = strings.TrimSpace(req.OutputPath)
|
||||||
|
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||||
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outputPath = req.OutputDir + "/" + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||||
|
|
||||||
|
// Parallel fetch cover art + lyrics
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
if req.EmbedLyrics || req.CoverURL != "" {
|
||||||
|
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||||
|
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsLRC := ""
|
||||||
|
var coverData []byte
|
||||||
|
if parallelResult != nil {
|
||||||
|
if parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
|
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
||||||
|
}
|
||||||
|
if parallelResult.CoverData != nil {
|
||||||
|
coverData = parallelResult.CoverData
|
||||||
|
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return YouTubeDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: req.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber,
|
||||||
|
ISRC: req.ISRC,
|
||||||
|
Format: format,
|
||||||
|
Bitrate: bitrate,
|
||||||
|
LyricsLRC: lyricsLRC,
|
||||||
|
CoverData: coverData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -142,6 +142,27 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "checkDuplicatesBatch":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let outputDir = args["output_dir"] as! String
|
||||||
|
let tracksJson = args["tracks"] as? String ?? "[]"
|
||||||
|
let response = GobackendCheckDuplicatesBatch(outputDir, tracksJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "preBuildDuplicateIndex":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let outputDir = args["output_dir"] as! String
|
||||||
|
GobackendPreBuildDuplicateIndex(outputDir, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "invalidateDuplicateIndex":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let outputDir = args["output_dir"] as! String
|
||||||
|
GobackendInvalidateDuplicateIndex(outputDir)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "buildFilename":
|
case "buildFilename":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let template = args["template"] as! String
|
let template = args["template"] as! String
|
||||||
@@ -196,12 +217,21 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "editFileMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let metadataJson = args["metadata_json"] as? String ?? "{}"
|
||||||
|
let response = GobackendEditFileMetadata(filePath, metadataJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "searchDeezerAll":
|
case "searchDeezerAll":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let query = args["query"] as! String
|
let query = args["query"] as! String
|
||||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||||
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error)
|
let filter = args["filter"] as? String ?? ""
|
||||||
|
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -220,6 +250,20 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "parseTidalUrl":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendParseTidalURLExport(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "convertTidalToSpotifyDeezer":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendConvertTidalToSpotifyDeezer(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "searchDeezerByISRC":
|
case "searchDeezerByISRC":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let isrc = args["isrc"] as! String
|
let isrc = args["isrc"] as! String
|
||||||
@@ -249,6 +293,43 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "checkAvailabilityFromDeezerID":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
let response = GobackendCheckAvailabilityFromDeezerID(deezerTrackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "checkAvailabilityByPlatformID":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let platform = args["platform"] as! String
|
||||||
|
let entityType = args["entity_type"] as! String
|
||||||
|
let entityId = args["entity_id"] as! String
|
||||||
|
let response = GobackendCheckAvailabilityByPlatformID(platform, entityType, entityId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getSpotifyIDFromDeezerTrack":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
let response = GobackendGetSpotifyIDFromDeezerTrack(deezerTrackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getTidalURLFromDeezerTrack":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
let response = GobackendGetTidalURLFromDeezerTrack(deezerTrackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getAmazonURLFromDeezerTrack":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
|
let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "preWarmTrackCache":
|
case "preWarmTrackCache":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let tracksJson = args["tracks"] as! String
|
let tracksJson = args["tracks"] as! String
|
||||||
@@ -404,6 +485,14 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "enrichTrackWithExtension":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let trackJson = args["track"] as? String ?? "{}"
|
||||||
|
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "removeExtension":
|
case "removeExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -558,6 +647,14 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
|
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "runPostProcessingV2":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let inputJson = args["input"] as? String ?? ""
|
||||||
|
let metadataJson = args["metadata"] as? String ?? ""
|
||||||
|
let response = GobackendRunPostProcessingV2JSON(inputJson, metadataJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "getPostProcessingProviders":
|
case "getPostProcessingProviders":
|
||||||
let response = GobackendGetPostProcessingProvidersJSON(&error)
|
let response = GobackendGetPostProcessingProvidersJSON(&error)
|
||||||
@@ -605,6 +702,58 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
// Extension Home Feed API
|
||||||
|
case "getExtensionHomeFeed":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getExtensionBrowseCategories":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let response = GobackendGetExtensionBrowseCategoriesJSON(extensionId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
// Local Library Scanning
|
||||||
|
case "setLibraryCoverCacheDir":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let cacheDir = args["cache_dir"] as! String
|
||||||
|
GobackendSetLibraryCoverCacheDirJSON(cacheDir)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "scanLibraryFolder":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let folderPath = args["folder_path"] as! String
|
||||||
|
let response = GobackendScanLibraryFolderJSON(folderPath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "scanLibraryFolderIncremental":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let folderPath = args["folder_path"] as! String
|
||||||
|
let existingFiles = args["existing_files"] as? String ?? "{}"
|
||||||
|
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getLibraryScanProgress":
|
||||||
|
let response = GobackendGetLibraryScanProgressJSON()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "cancelLibraryScan":
|
||||||
|
GobackendCancelLibraryScanJSON()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "readAudioMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendReadAudioMetadataJSON(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
||||||
<!-- File Sharing - Allow access via Files app -->
|
<!-- File Sharing - Allow access via Files app -->
|
||||||
@@ -81,5 +81,29 @@
|
|||||||
<!-- Photo Library (for cover art if needed) -->
|
<!-- Photo Library (for cover art if needed) -->
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>SpotiFLAC needs access to save album artwork</string>
|
<string>SpotiFLAC needs access to save album artwork</string>
|
||||||
|
|
||||||
|
<!-- URL Schemes for deep linking -->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Viewer</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.zarz.spotiflac</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>spotiflac</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Associated Domains for Universal Links -->
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>spotify</string>
|
||||||
|
<string>deezer</string>
|
||||||
|
<string>tidal</string>
|
||||||
|
<string>youtube-music</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -4,15 +4,27 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotiflac_android/screens/main_shell.dart';
|
import 'package:spotiflac_android/screens/main_shell.dart';
|
||||||
import 'package:spotiflac_android/screens/setup_screen.dart';
|
import 'package:spotiflac_android/screens/setup_screen.dart';
|
||||||
|
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
|
||||||
|
|
||||||
|
// Determine initial location based on app state
|
||||||
|
String initialLocation;
|
||||||
|
if (isFirstLaunch) {
|
||||||
|
initialLocation = '/setup';
|
||||||
|
} else if (!hasCompletedTutorial) {
|
||||||
|
initialLocation = '/tutorial';
|
||||||
|
} else {
|
||||||
|
initialLocation = '/';
|
||||||
|
}
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: isFirstLaunch ? '/setup' : '/',
|
initialLocation: initialLocation,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -22,6 +34,10 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/setup',
|
path: '/setup',
|
||||||
builder: (context, state) => const SetupScreen(),
|
builder: (context, state) => const SetupScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/tutorial',
|
||||||
|
builder: (context, state) => const TutorialScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.1.3';
|
static const String version = '3.6.5';
|
||||||
static const String buildNumber = '62';
|
static const String buildNumber = '79';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
@@ -17,4 +17,5 @@ class AppInfo {
|
|||||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
|
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Home';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get navLibrary => 'Library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHistory => 'History';
|
String get navHistory => 'History';
|
||||||
|
|
||||||
@@ -109,6 +112,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -337,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@@ -429,15 +439,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChannel => 'Telegram Channel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChat => 'Telegram Community';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSocial => 'Social';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -452,6 +471,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get aboutSachinsenalDesc =>
|
String get aboutSachinsenalDesc =>
|
||||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSjdonadoDesc =>
|
||||||
|
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutDoubleDouble => 'DoubleDouble';
|
String get aboutDoubleDouble => 'DoubleDouble';
|
||||||
|
|
||||||
@@ -466,6 +489,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
@@ -662,6 +692,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -917,6 +951,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '\"$trackName\" already downloaded';
|
return '\"$trackName\" already downloaded';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarAlreadyInLibrary(String trackName) {
|
||||||
|
return '\"$trackName\" already exists in your library';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get snackbarHistoryCleared => 'History cleared';
|
String get snackbarHistoryCleared => 'History cleared';
|
||||||
|
|
||||||
@@ -1600,6 +1639,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1829,25 +1877,45 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityMp3 => 'MP3';
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3Option => 'Enable MP3 Option';
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3OptionSubtitleOff =>
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
'Downloads FLAC then converts to 320kbps MP3';
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOff =>
|
||||||
|
'Downloads FLAC then converts to lossy format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatOpusSubtitle =>
|
||||||
|
'128kbps, better quality at smaller size';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1860,6 +1928,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -1939,6 +2029,39 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetwork => 'Download Network';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkSubtitle =>
|
||||||
|
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
@@ -1988,6 +2111,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2055,6 +2185,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2064,4 +2200,739 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $message';
|
return 'Error: $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownload => 'Download Discography';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownloadAll => 'Download All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount releases';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyAlbumsOnly => 'Albums Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount albums';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount singles';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySelectAlbums => 'Select Albums...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySelectAlbumsSubtitle =>
|
||||||
|
'Choose specific albums or singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyFetchingAlbum(int current, int total) {
|
||||||
|
return 'Fetching $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySelectedCount(int count) {
|
||||||
|
return '$count selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownloadSelected => 'Download Selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyAddedToQueue(int count) {
|
||||||
|
return 'Added $count tracks to queue';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySkippedDownloaded(int added, int skipped) {
|
||||||
|
return '$added added, $skipped already downloaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyNoAlbums => 'No albums available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionStorageAccess => 'Storage Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccess => 'All Files Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDescription =>
|
||||||
|
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDeniedMessage =>
|
||||||
|
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDisabledMessage =>
|
||||||
|
'All Files Access disabled. The app will use limited storage access.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLocalLibrary => 'Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStatus => 'Library Status';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSettings => 'Scan Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryEnableLocalLibrarySubtitle =>
|
||||||
|
'Scan and track your existing music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolder => 'Library Folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolderHint => 'Tap to select folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScan => 'Scan Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSubtitle => 'Scan for audio files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleanupMissingFilesSubtitle =>
|
||||||
|
'Remove entries for files that no longer exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClear => 'Clear Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearConfirmTitle => 'Clear Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearConfirmMessage =>
|
||||||
|
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAbout => 'About Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAboutDescription =>
|
||||||
|
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryTracksCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryLastScanned(String time) {
|
||||||
|
return 'Last scanned: $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryLastScannedNever => 'Never';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryScanProgress(String progress, int total) {
|
||||||
|
return '$progress% of $total files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryInLibrary => 'In Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryRemovedMissingFiles(int count) {
|
||||||
|
return 'Removed $count missing files from library';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleared => 'Library cleared';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStorageAccessMessage =>
|
||||||
|
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySourceDownloaded => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySourceLocal => 'Local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterAll => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDownloaded => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterLocal => 'Local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterTitle => 'Filters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterReset => 'Reset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterApply => 'Apply';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSource => 'Source';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQuality => 'Quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDate => 'Date Added';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateToday => 'Today';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateWeek => 'This Week';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateMonth => 'This Month';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilterActive(int count) {
|
||||||
|
return '$count filter(s) active';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String timeMinutesAgo(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count minutes ago',
|
||||||
|
one: '1 minute ago',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String timeHoursAgo(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hours ago',
|
||||||
|
one: '1 hour ago',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToSafMessage =>
|
||||||
|
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToAppMessage =>
|
||||||
|
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||||
|
return '$count tracks in $mode storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchNewDownloads => 'New Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageSwitchNewDownloadsLocation(String location) {
|
||||||
|
return 'Will be saved to: $location';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchContinue => 'Continue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageAppStorage => 'App Storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSafStorage => 'SAF Storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageModeBadge(String mode) {
|
||||||
|
return 'Storage: $mode';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageStatsTitle => 'Storage Statistics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageStatsAppCount(int count) {
|
||||||
|
return '$count tracks in App Storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageStatsSafCount(int count) {
|
||||||
|
return '$count tracks in SAF Storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeDesc =>
|
||||||
|
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip1 =>
|
||||||
|
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip2 =>
|
||||||
|
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip3 =>
|
||||||
|
'Automatic metadata, cover art, and lyrics embedding';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTitle => 'Finding Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchDesc =>
|
||||||
|
'There are two easy ways to find music you want to download.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip1 =>
|
||||||
|
'Paste a Spotify or Deezer URL directly in the search box';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip2 =>
|
||||||
|
'Or type the song name, artist, or album to search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip3 =>
|
||||||
|
'Supports tracks, albums, playlists, and artist pages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTitle => 'Downloading Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadDesc =>
|
||||||
|
'Downloading music is simple and fast. Here\'s how it works.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip1 =>
|
||||||
|
'Tap the download button next to any track to start downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip2 =>
|
||||||
|
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip3 =>
|
||||||
|
'Download entire albums or playlists with one tap';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTitle => 'Your Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryDesc =>
|
||||||
|
'All your downloaded music is organized in the Library tab.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip1 =>
|
||||||
|
'View download progress and queue in the Library tab';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip2 =>
|
||||||
|
'Tap any track to play it with your music player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip3 =>
|
||||||
|
'Switch between list and grid view for better browsing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTitle => 'Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsDesc =>
|
||||||
|
'Extend the app\'s capabilities with community extensions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip1 =>
|
||||||
|
'Browse the Store tab to discover useful extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip2 =>
|
||||||
|
'Add new download providers or search sources';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip3 =>
|
||||||
|
'Get lyrics, enhanced metadata, and more features';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsDesc =>
|
||||||
|
'Personalize the app in Settings to match your preferences.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip1 =>
|
||||||
|
'Change download location and folder organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip2 =>
|
||||||
|
'Set default audio quality and format preferences';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialReadyMessage =>
|
||||||
|
'You\'re all set! Start downloading your favorite music now.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExample => 'EXAMPLE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryForceFullScan => 'Force Full Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloadsSubtitle =>
|
||||||
|
'Remove history entries for files that no longer exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cleanupOrphanedDownloadsResult(int count) {
|
||||||
|
return 'Removed $count orphaned entries from history';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmMessage =>
|
||||||
|
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAll => 'Clear all cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Home';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get navLibrary => 'Library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHistory => 'History';
|
String get navHistory => 'History';
|
||||||
|
|
||||||
@@ -109,6 +112,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -337,6 +343,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@@ -429,15 +439,24 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChannel => 'Telegram Channel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChat => 'Telegram Community';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSocial => 'Social';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -452,6 +471,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get aboutSachinsenalDesc =>
|
String get aboutSachinsenalDesc =>
|
||||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSjdonadoDesc =>
|
||||||
|
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutDoubleDouble => 'DoubleDouble';
|
String get aboutDoubleDouble => 'DoubleDouble';
|
||||||
|
|
||||||
@@ -466,6 +489,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
@@ -662,6 +692,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -917,6 +951,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return '\"$trackName\" already downloaded';
|
return '\"$trackName\" already downloaded';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarAlreadyInLibrary(String trackName) {
|
||||||
|
return '\"$trackName\" already exists in your library';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get snackbarHistoryCleared => 'History cleared';
|
String get snackbarHistoryCleared => 'History cleared';
|
||||||
|
|
||||||
@@ -1600,6 +1639,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1829,25 +1877,45 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityMp3 => 'MP3';
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3Option => 'Enable MP3 Option';
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3OptionSubtitleOff =>
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
'Downloads FLAC then converts to 320kbps MP3';
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOff =>
|
||||||
|
'Downloads FLAC then converts to lossy format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatOpusSubtitle =>
|
||||||
|
'128kbps, better quality at smaller size';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1860,6 +1928,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -1939,6 +2029,39 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetwork => 'Download Network';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkSubtitle =>
|
||||||
|
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
@@ -1988,6 +2111,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2055,6 +2185,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2064,4 +2200,739 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $message';
|
return 'Error: $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownload => 'Download Discography';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownloadAll => 'Download All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount releases';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyAlbumsOnly => 'Albums Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount albums';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount singles';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySelectAlbums => 'Select Albums...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySelectAlbumsSubtitle =>
|
||||||
|
'Choose specific albums or singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyFetchingAlbum(int current, int total) {
|
||||||
|
return 'Fetching $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySelectedCount(int count) {
|
||||||
|
return '$count selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownloadSelected => 'Download Selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyAddedToQueue(int count) {
|
||||||
|
return 'Added $count tracks to queue';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySkippedDownloaded(int added, int skipped) {
|
||||||
|
return '$added added, $skipped already downloaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyNoAlbums => 'No albums available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionStorageAccess => 'Storage Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccess => 'All Files Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDescription =>
|
||||||
|
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDeniedMessage =>
|
||||||
|
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDisabledMessage =>
|
||||||
|
'All Files Access disabled. The app will use limited storage access.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLocalLibrary => 'Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStatus => 'Library Status';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSettings => 'Scan Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryEnableLocalLibrarySubtitle =>
|
||||||
|
'Scan and track your existing music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolder => 'Library Folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolderHint => 'Tap to select folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScan => 'Scan Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSubtitle => 'Scan for audio files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleanupMissingFilesSubtitle =>
|
||||||
|
'Remove entries for files that no longer exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClear => 'Clear Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearConfirmTitle => 'Clear Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearConfirmMessage =>
|
||||||
|
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAbout => 'About Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAboutDescription =>
|
||||||
|
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryTracksCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryLastScanned(String time) {
|
||||||
|
return 'Last scanned: $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryLastScannedNever => 'Never';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryScanProgress(String progress, int total) {
|
||||||
|
return '$progress% of $total files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryInLibrary => 'In Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryRemovedMissingFiles(int count) {
|
||||||
|
return 'Removed $count missing files from library';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleared => 'Library cleared';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStorageAccessMessage =>
|
||||||
|
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySourceDownloaded => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySourceLocal => 'Local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterAll => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDownloaded => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterLocal => 'Local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterTitle => 'Filters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterReset => 'Reset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterApply => 'Apply';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSource => 'Source';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQuality => 'Quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDate => 'Date Added';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateToday => 'Today';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateWeek => 'This Week';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateMonth => 'This Month';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilterActive(int count) {
|
||||||
|
return '$count filter(s) active';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String timeMinutesAgo(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count minutes ago',
|
||||||
|
one: '1 minute ago',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String timeHoursAgo(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hours ago',
|
||||||
|
one: '1 hour ago',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToSafMessage =>
|
||||||
|
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToAppMessage =>
|
||||||
|
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||||
|
return '$count tracks in $mode storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchNewDownloads => 'New Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageSwitchNewDownloadsLocation(String location) {
|
||||||
|
return 'Will be saved to: $location';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchContinue => 'Continue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageAppStorage => 'App Storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSafStorage => 'SAF Storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageModeBadge(String mode) {
|
||||||
|
return 'Storage: $mode';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageStatsTitle => 'Storage Statistics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageStatsAppCount(int count) {
|
||||||
|
return '$count tracks in App Storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageStatsSafCount(int count) {
|
||||||
|
return '$count tracks in SAF Storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeDesc =>
|
||||||
|
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip1 =>
|
||||||
|
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip2 =>
|
||||||
|
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip3 =>
|
||||||
|
'Automatic metadata, cover art, and lyrics embedding';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTitle => 'Finding Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchDesc =>
|
||||||
|
'There are two easy ways to find music you want to download.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip1 =>
|
||||||
|
'Paste a Spotify or Deezer URL directly in the search box';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip2 =>
|
||||||
|
'Or type the song name, artist, or album to search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip3 =>
|
||||||
|
'Supports tracks, albums, playlists, and artist pages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTitle => 'Downloading Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadDesc =>
|
||||||
|
'Downloading music is simple and fast. Here\'s how it works.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip1 =>
|
||||||
|
'Tap the download button next to any track to start downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip2 =>
|
||||||
|
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip3 =>
|
||||||
|
'Download entire albums or playlists with one tap';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTitle => 'Your Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryDesc =>
|
||||||
|
'All your downloaded music is organized in the Library tab.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip1 =>
|
||||||
|
'View download progress and queue in the Library tab';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip2 =>
|
||||||
|
'Tap any track to play it with your music player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip3 =>
|
||||||
|
'Switch between list and grid view for better browsing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTitle => 'Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsDesc =>
|
||||||
|
'Extend the app\'s capabilities with community extensions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip1 =>
|
||||||
|
'Browse the Store tab to discover useful extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip2 =>
|
||||||
|
'Add new download providers or search sources';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip3 =>
|
||||||
|
'Get lyrics, enhanced metadata, and more features';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsDesc =>
|
||||||
|
'Personalize the app in Settings to match your preferences.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip1 =>
|
||||||
|
'Change download location and folder organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip2 =>
|
||||||
|
'Set default audio quality and format preferences';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialReadyMessage =>
|
||||||
|
'You\'re all set! Start downloading your favorite music now.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExample => 'EXAMPLE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryForceFullScan => 'Force Full Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloadsSubtitle =>
|
||||||
|
'Remove history entries for files that no longer exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cleanupOrphanedDownloadsResult(int count) {
|
||||||
|
return 'Removed $count orphaned entries from history';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmMessage =>
|
||||||
|
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAll => 'Clear all cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Home';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get navLibrary => 'Library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHistory => 'History';
|
String get navHistory => 'History';
|
||||||
|
|
||||||
@@ -109,6 +112,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -337,6 +343,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@@ -429,15 +439,24 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChannel => 'Telegram Channel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChat => 'Telegram Community';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSocial => 'Social';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -452,6 +471,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get aboutSachinsenalDesc =>
|
String get aboutSachinsenalDesc =>
|
||||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSjdonadoDesc =>
|
||||||
|
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutDoubleDouble => 'DoubleDouble';
|
String get aboutDoubleDouble => 'DoubleDouble';
|
||||||
|
|
||||||
@@ -466,6 +489,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
@@ -662,6 +692,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -917,6 +951,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return '\"$trackName\" already downloaded';
|
return '\"$trackName\" already downloaded';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarAlreadyInLibrary(String trackName) {
|
||||||
|
return '\"$trackName\" already exists in your library';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get snackbarHistoryCleared => 'History cleared';
|
String get snackbarHistoryCleared => 'History cleared';
|
||||||
|
|
||||||
@@ -1600,6 +1639,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1829,25 +1877,45 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityMp3 => 'MP3';
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3Option => 'Enable MP3 Option';
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3OptionSubtitleOff =>
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
'Downloads FLAC then converts to 320kbps MP3';
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOff =>
|
||||||
|
'Downloads FLAC then converts to lossy format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatOpusSubtitle =>
|
||||||
|
'128kbps, better quality at smaller size';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1860,6 +1928,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -1939,6 +2029,39 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetwork => 'Download Network';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkSubtitle =>
|
||||||
|
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
@@ -1988,6 +2111,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2055,6 +2185,12 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2064,4 +2200,739 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $message';
|
return 'Error: $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownload => 'Download Discography';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownloadAll => 'Download All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount releases';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyAlbumsOnly => 'Albums Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount albums';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount singles';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySelectAlbums => 'Select Albums...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySelectAlbumsSubtitle =>
|
||||||
|
'Choose specific albums or singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyFetchingAlbum(int current, int total) {
|
||||||
|
return 'Fetching $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySelectedCount(int count) {
|
||||||
|
return '$count selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownloadSelected => 'Download Selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyAddedToQueue(int count) {
|
||||||
|
return 'Added $count tracks to queue';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySkippedDownloaded(int added, int skipped) {
|
||||||
|
return '$added added, $skipped already downloaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyNoAlbums => 'No albums available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionStorageAccess => 'Storage Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccess => 'All Files Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDescription =>
|
||||||
|
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDeniedMessage =>
|
||||||
|
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDisabledMessage =>
|
||||||
|
'All Files Access disabled. The app will use limited storage access.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLocalLibrary => 'Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStatus => 'Library Status';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSettings => 'Scan Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryEnableLocalLibrarySubtitle =>
|
||||||
|
'Scan and track your existing music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolder => 'Library Folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolderHint => 'Tap to select folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScan => 'Scan Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSubtitle => 'Scan for audio files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleanupMissingFilesSubtitle =>
|
||||||
|
'Remove entries for files that no longer exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClear => 'Clear Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearConfirmTitle => 'Clear Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearConfirmMessage =>
|
||||||
|
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAbout => 'About Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAboutDescription =>
|
||||||
|
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryTracksCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryLastScanned(String time) {
|
||||||
|
return 'Last scanned: $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryLastScannedNever => 'Never';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryScanProgress(String progress, int total) {
|
||||||
|
return '$progress% of $total files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryInLibrary => 'In Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryRemovedMissingFiles(int count) {
|
||||||
|
return 'Removed $count missing files from library';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleared => 'Library cleared';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStorageAccessMessage =>
|
||||||
|
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySourceDownloaded => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySourceLocal => 'Local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterAll => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDownloaded => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterLocal => 'Local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterTitle => 'Filters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterReset => 'Reset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterApply => 'Apply';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSource => 'Source';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQuality => 'Quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDate => 'Date Added';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateToday => 'Today';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateWeek => 'This Week';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateMonth => 'This Month';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilterActive(int count) {
|
||||||
|
return '$count filter(s) active';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String timeMinutesAgo(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count minutes ago',
|
||||||
|
one: '1 minute ago',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String timeHoursAgo(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hours ago',
|
||||||
|
one: '1 hour ago',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToSafMessage =>
|
||||||
|
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToAppMessage =>
|
||||||
|
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||||
|
return '$count tracks in $mode storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchNewDownloads => 'New Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageSwitchNewDownloadsLocation(String location) {
|
||||||
|
return 'Will be saved to: $location';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchContinue => 'Continue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageAppStorage => 'App Storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSafStorage => 'SAF Storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageModeBadge(String mode) {
|
||||||
|
return 'Storage: $mode';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageStatsTitle => 'Storage Statistics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageStatsAppCount(int count) {
|
||||||
|
return '$count tracks in App Storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageStatsSafCount(int count) {
|
||||||
|
return '$count tracks in SAF Storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeDesc =>
|
||||||
|
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip1 =>
|
||||||
|
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip2 =>
|
||||||
|
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip3 =>
|
||||||
|
'Automatic metadata, cover art, and lyrics embedding';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTitle => 'Finding Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchDesc =>
|
||||||
|
'There are two easy ways to find music you want to download.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip1 =>
|
||||||
|
'Paste a Spotify or Deezer URL directly in the search box';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip2 =>
|
||||||
|
'Or type the song name, artist, or album to search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip3 =>
|
||||||
|
'Supports tracks, albums, playlists, and artist pages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTitle => 'Downloading Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadDesc =>
|
||||||
|
'Downloading music is simple and fast. Here\'s how it works.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip1 =>
|
||||||
|
'Tap the download button next to any track to start downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip2 =>
|
||||||
|
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip3 =>
|
||||||
|
'Download entire albums or playlists with one tap';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTitle => 'Your Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryDesc =>
|
||||||
|
'All your downloaded music is organized in the Library tab.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip1 =>
|
||||||
|
'View download progress and queue in the Library tab';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip2 =>
|
||||||
|
'Tap any track to play it with your music player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip3 =>
|
||||||
|
'Switch between list and grid view for better browsing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTitle => 'Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsDesc =>
|
||||||
|
'Extend the app\'s capabilities with community extensions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip1 =>
|
||||||
|
'Browse the Store tab to discover useful extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip2 =>
|
||||||
|
'Add new download providers or search sources';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip3 =>
|
||||||
|
'Get lyrics, enhanced metadata, and more features';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsDesc =>
|
||||||
|
'Personalize the app in Settings to match your preferences.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip1 =>
|
||||||
|
'Change download location and folder organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip2 =>
|
||||||
|
'Set default audio quality and format preferences';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialReadyMessage =>
|
||||||
|
'You\'re all set! Start downloading your favorite music now.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExample => 'EXAMPLE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryForceFullScan => 'Force Full Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloadsSubtitle =>
|
||||||
|
'Remove history entries for files that no longer exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cleanupOrphanedDownloadsResult(int count) {
|
||||||
|
return 'Removed $count orphaned entries from history';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmMessage =>
|
||||||
|
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAll => 'Clear all cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'Home';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get navLibrary => 'Library';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHistory => 'History';
|
String get navHistory => 'History';
|
||||||
|
|
||||||
@@ -109,6 +112,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Single track downloads will appear here';
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
||||||
@@ -337,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get optionsSpotifyWarning =>
|
String get optionsSpotifyWarning =>
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get optionsSpotifyDeprecationWarning =>
|
||||||
|
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionsTitle => 'Extensions';
|
String get extensionsTitle => 'Extensions';
|
||||||
|
|
||||||
@@ -429,15 +439,24 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChannel => 'Telegram Channel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChat => 'Telegram Community';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTelegramChatSubtitle => 'Chat with other users';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSocial => 'Social';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSupport => 'Support';
|
String get aboutSupport => 'Support';
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffee => 'Buy me a coffee';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutApp => 'App';
|
String get aboutApp => 'App';
|
||||||
|
|
||||||
@@ -452,6 +471,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get aboutSachinsenalDesc =>
|
String get aboutSachinsenalDesc =>
|
||||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
'The original HiFi project creator. The foundation of Tidal integration!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSjdonadoDesc =>
|
||||||
|
'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutDoubleDouble => 'DoubleDouble';
|
String get aboutDoubleDouble => 'DoubleDouble';
|
||||||
|
|
||||||
@@ -466,6 +489,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaver => 'SpotiSaver';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutSpotiSaverDesc =>
|
||||||
|
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||||
@@ -662,6 +692,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get setupIosEmptyFolderWarning =>
|
String get setupIosEmptyFolderWarning =>
|
||||||
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setupIcloudNotSupported =>
|
||||||
|
'iCloud Drive is not supported. Please use the app Documents folder.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
|
||||||
|
|
||||||
@@ -917,6 +951,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return '\"$trackName\" already downloaded';
|
return '\"$trackName\" already downloaded';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarAlreadyInLibrary(String trackName) {
|
||||||
|
return '\"$trackName\" already exists in your library';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get snackbarHistoryCleared => 'History cleared';
|
String get snackbarHistoryCleared => 'History cleared';
|
||||||
|
|
||||||
@@ -1600,6 +1639,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||||
|
|
||||||
@@ -1829,25 +1877,45 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityMp3 => 'MP3';
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3Option => 'Enable MP3 Option';
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get enableMp3OptionSubtitleOff =>
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
'Downloads FLAC then converts to 320kbps MP3';
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOff =>
|
||||||
|
'Downloads FLAC then converts to lossy format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormat => 'Lossy Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatDescription => 'Choose the lossy format for conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatMp3Subtitle => '320kbps, best compatibility';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lossyFormatOpusSubtitle =>
|
||||||
|
'128kbps, better quality at smaller size';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youtubeQualityNote =>
|
||||||
|
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1860,6 +1928,28 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||||
|
'Artist folders use Album Artist when available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
|
'Artist folders use Track Artist only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||||
|
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||||
|
'Full artist string used for folder name';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSaveFormat => 'Save Format';
|
String get downloadSaveFormat => 'Save Format';
|
||||||
|
|
||||||
@@ -1939,6 +2029,39 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get queueClearAllMessage =>
|
String get queueClearAllMessage =>
|
||||||
'Are you sure you want to clear all downloads?';
|
'Are you sure you want to clear all downloads?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailed => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedSuccess =>
|
||||||
|
'Failed downloads exported to TXT file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedClear => 'Clear Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueExportFailedError => 'Failed to export downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAutoExportFailedSubtitle =>
|
||||||
|
'Save failed downloads to TXT file automatically';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetwork => 'Download Network';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDownloadNetworkSubtitle =>
|
||||||
|
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get queueEmpty => 'No downloads in queue';
|
String get queueEmpty => 'No downloads in queue';
|
||||||
|
|
||||||
@@ -1988,6 +2111,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -2055,6 +2185,12 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get recentTypePlaylist => 'Playlist';
|
String get recentTypePlaylist => 'Playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentEmpty => 'No recent items yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recentShowAllDownloads => 'Show All Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recentPlaylistInfo(String name) {
|
String recentPlaylistInfo(String name) {
|
||||||
return 'Playlist: $name';
|
return 'Playlist: $name';
|
||||||
@@ -2064,4 +2200,739 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String errorGeneric(String message) {
|
String errorGeneric(String message) {
|
||||||
return 'Error: $message';
|
return 'Error: $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownload => 'Download Discography';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownloadAll => 'Download All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyDownloadAllSubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount releases';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyAlbumsOnly => 'Albums Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount albums';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySinglesOnly => 'Singles & EPs Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySinglesOnlySubtitle(int count, int albumCount) {
|
||||||
|
return '$count tracks from $albumCount singles';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySelectAlbums => 'Select Albums...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographySelectAlbumsSubtitle =>
|
||||||
|
'Choose specific albums or singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyFetchingTracks => 'Fetching tracks...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyFetchingAlbum(int current, int total) {
|
||||||
|
return 'Fetching $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySelectedCount(int count) {
|
||||||
|
return '$count selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyDownloadSelected => 'Download Selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographyAddedToQueue(int count) {
|
||||||
|
return 'Added $count tracks to queue';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String discographySkippedDownloaded(int added, int skipped) {
|
||||||
|
return '$added added, $skipped already downloaded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyNoAlbums => 'No albums available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionStorageAccess => 'Storage Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccess => 'All Files Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDescription =>
|
||||||
|
'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDeniedMessage =>
|
||||||
|
'Permission was denied. Please enable \'All files access\' manually in system settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allFilesAccessDisabledMessage =>
|
||||||
|
'All Files Access disabled. The app will use limited storage access.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLocalLibrary => 'Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCache => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryTitle => 'Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStatus => 'Library Status';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSettings => 'Scan Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryEnableLocalLibrary => 'Enable Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryEnableLocalLibrarySubtitle =>
|
||||||
|
'Scan and track your existing music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolder => 'Library Folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolderHint => 'Tap to select folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScan => 'Scan Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSubtitle => 'Scan for audio files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanSelectFolderFirst => 'Select a folder first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleanupMissingFilesSubtitle =>
|
||||||
|
'Remove entries for files that no longer exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClear => 'Clear Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearSubtitle => 'Remove all scanned tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearConfirmTitle => 'Clear Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryClearConfirmMessage =>
|
||||||
|
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAbout => 'About Local Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAboutDescription =>
|
||||||
|
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryTracksCount(int count) {
|
||||||
|
return '$count tracks';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryLastScanned(String time) {
|
||||||
|
return 'Last scanned: $time';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryLastScannedNever => 'Never';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryScanning => 'Scanning...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryScanProgress(String progress, int total) {
|
||||||
|
return '$progress% of $total files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryInLibrary => 'In Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryRemovedMissingFiles(int count) {
|
||||||
|
return 'Removed $count missing files from library';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryCleared => 'Library cleared';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStorageAccessRequired => 'Storage Access Required';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStorageAccessMessage =>
|
||||||
|
'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFolderNotExist => 'Selected folder does not exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySourceDownloaded => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySourceLocal => 'Local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterAll => 'All';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDownloaded => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterLocal => 'Local';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterTitle => 'Filters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterReset => 'Reset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterApply => 'Apply';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSource => 'Source';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQuality => 'Quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityCD => 'CD (16bit)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterQualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterFormat => 'Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDate => 'Date Added';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateToday => 'Today';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateWeek => 'This Week';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateMonth => 'This Month';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterDateYear => 'This Year';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSort => 'Sort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortLatest => 'Latest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryFilterSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String libraryFilterActive(int count) {
|
||||||
|
return '$count filter(s) active';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get timeJustNow => 'Just now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String timeMinutesAgo(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count minutes ago',
|
||||||
|
one: '1 minute ago',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String timeHoursAgo(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count hours ago',
|
||||||
|
one: '1 hour ago',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchTitle => 'Switch Storage Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToAppTitle => 'Switch to App Storage?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToSafMessage =>
|
||||||
|
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchToAppMessage =>
|
||||||
|
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchExistingDownloads => 'Existing Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageSwitchExistingDownloadsInfo(int count, String mode) {
|
||||||
|
return '$count tracks in $mode storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchNewDownloads => 'New Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageSwitchNewDownloadsLocation(String location) {
|
||||||
|
return 'Will be saved to: $location';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchContinue => 'Continue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSwitchSelectFolder => 'Select SAF Folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageAppStorage => 'App Storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageSafStorage => 'SAF Storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageModeBadge(String mode) {
|
||||||
|
return 'Storage: $mode';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageStatsTitle => 'Storage Statistics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageStatsAppCount(int count) {
|
||||||
|
return '$count tracks in App Storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String storageStatsSafCount(int count) {
|
||||||
|
return '$count tracks in SAF Storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storageModeInfo => 'Your files are stored in multiple locations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeDesc =>
|
||||||
|
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip1 =>
|
||||||
|
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip2 =>
|
||||||
|
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialWelcomeTip3 =>
|
||||||
|
'Automatic metadata, cover art, and lyrics embedding';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTitle => 'Finding Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchDesc =>
|
||||||
|
'There are two easy ways to find music you want to download.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip1 =>
|
||||||
|
'Paste a Spotify or Deezer URL directly in the search box';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip2 =>
|
||||||
|
'Or type the song name, artist, or album to search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSearchTip3 =>
|
||||||
|
'Supports tracks, albums, playlists, and artist pages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTitle => 'Downloading Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadDesc =>
|
||||||
|
'Downloading music is simple and fast. Here\'s how it works.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip1 =>
|
||||||
|
'Tap the download button next to any track to start downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip2 =>
|
||||||
|
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialDownloadTip3 =>
|
||||||
|
'Download entire albums or playlists with one tap';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTitle => 'Your Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryDesc =>
|
||||||
|
'All your downloaded music is organized in the Library tab.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip1 =>
|
||||||
|
'View download progress and queue in the Library tab';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip2 =>
|
||||||
|
'Tap any track to play it with your music player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialLibraryTip3 =>
|
||||||
|
'Switch between list and grid view for better browsing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTitle => 'Extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsDesc =>
|
||||||
|
'Extend the app\'s capabilities with community extensions.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip1 =>
|
||||||
|
'Browse the Store tab to discover useful extensions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip2 =>
|
||||||
|
'Add new download providers or search sources';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExtensionsTip3 =>
|
||||||
|
'Get lyrics, enhanced metadata, and more features';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTitle => 'Customize Your Experience';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsDesc =>
|
||||||
|
'Personalize the app in Settings to match your preferences.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip1 =>
|
||||||
|
'Change download location and folder organization';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip2 =>
|
||||||
|
'Set default audio quality and format preferences';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialReadyMessage =>
|
||||||
|
'You\'re all set! Start downloading your favorite music now.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tutorialExample => 'EXAMPLE';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryForceFullScan => 'Force Full Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloadsSubtitle =>
|
||||||
|
'Remove history entries for files that no longer exist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cleanupOrphanedDownloadsResult(int count) {
|
||||||
|
return 'Removed $count orphaned entries from history';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTitle => 'Storage & Cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummaryTitle => 'Cache overview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSummarySubtitle =>
|
||||||
|
'Clearing cache will not remove downloaded music files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEstimatedTotal(String size) {
|
||||||
|
return 'Estimated cache usage: $size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionStorage => 'Cached Data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheSectionMaintenance => 'Maintenance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectory => 'App cache directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheAppDirectoryDesc =>
|
||||||
|
'HTTP responses, WebView data, and other temporary app data.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectory => 'Temporary directory';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTempDirectoryDesc =>
|
||||||
|
'Temporary files from downloads and audio conversion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImage => 'Cover image cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCoverImageDesc =>
|
||||||
|
'Downloaded album and track cover art. Will re-download when viewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCover => 'Library cover cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheLibraryCoverDesc =>
|
||||||
|
'Cover art extracted from local music files. Will re-extract on next scan.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeed => 'Explore feed cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheExploreFeedDesc =>
|
||||||
|
'Explore tab content (new releases, trending). Will refresh on next visit.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookup => 'Track lookup cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheTrackLookupDesc =>
|
||||||
|
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedDesc =>
|
||||||
|
'Remove orphaned download history and library entries for missing files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheNoData => 'No cached data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeWithFiles(String size, int count) {
|
||||||
|
return '$size in $count files';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheSizeOnly(String size) {
|
||||||
|
return '$size';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheEntries(int count) {
|
||||||
|
return '$count entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearSuccess(String target) {
|
||||||
|
return 'Cleared: $target';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearConfirmTitle => 'Clear cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheClearConfirmMessage(String target) {
|
||||||
|
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmTitle => 'Clear all cache?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAllConfirmMessage =>
|
||||||
|
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheClearAll => 'Clear all cache';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnused => 'Cleanup unused data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheCleanupUnusedSubtitle =>
|
||||||
|
'Remove orphaned download history and missing library entries';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||||
|
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cacheRefreshStats => 'Refresh stats';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArt => 'Save Cover Art';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackSaveLyricsProgress => 'Saving lyrics...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrich => 'Re-enrich Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSubtitle =>
|
||||||
|
'Re-embed metadata without re-downloading';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichOnlineSubtitle =>
|
||||||
|
'Search metadata online and embed into file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEditMetadata => 'Edit Metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackCoverSaved(String fileName) {
|
||||||
|
return 'Cover art saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverNoSource => 'No cover art source available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackLyricsSaved(String fileName) {
|
||||||
|
return 'Lyrics saved to $fileName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichProgress => 'Re-enriching metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSearching => 'Searching metadata online...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackSaveFailed(String error) {
|
||||||
|
return 'Failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertTargetFormat => 'Target Format';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertBitrate => 'Bitrate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConfirmTitle => 'Confirm Conversion';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessage(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertSuccess(String format) {
|
||||||
|
return 'Converted to $format successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,10 @@
|
|||||||
"@historyNoSinglesSubtitle": {
|
"@historyNoSinglesSubtitle": {
|
||||||
"description": "Empty state subtitle for singles filter"
|
"description": "Empty state subtitle for singles filter"
|
||||||
},
|
},
|
||||||
|
"historySearchHint": "Suchverlauf...",
|
||||||
|
"@historySearchHint": {
|
||||||
|
"description": "Search bar placeholder in history"
|
||||||
|
},
|
||||||
"settingsTitle": "Einstellungen",
|
"settingsTitle": "Einstellungen",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
@@ -512,6 +516,10 @@
|
|||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
|
"aboutTranslators": "Übersetzer",
|
||||||
|
"@aboutTranslators": {
|
||||||
|
"description": "Section for translators"
|
||||||
|
},
|
||||||
"aboutSpecialThanks": "Besonderer Dank",
|
"aboutSpecialThanks": "Besonderer Dank",
|
||||||
"@aboutSpecialThanks": {
|
"@aboutSpecialThanks": {
|
||||||
"description": "Section for special thanks"
|
"description": "Section for special thanks"
|
||||||
@@ -544,18 +552,30 @@
|
|||||||
"@aboutFeatureRequestSubtitle": {
|
"@aboutFeatureRequestSubtitle": {
|
||||||
"description": "Subtitle for feature request"
|
"description": "Subtitle for feature request"
|
||||||
},
|
},
|
||||||
|
"aboutTelegramChannel": "Telegram Kanal",
|
||||||
|
"@aboutTelegramChannel": {
|
||||||
|
"description": "Link to Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChannelSubtitle": "Ankündigungen und Updates",
|
||||||
|
"@aboutTelegramChannelSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChat": "Telegram Community",
|
||||||
|
"@aboutTelegramChat": {
|
||||||
|
"description": "Link to Telegram chat group"
|
||||||
|
},
|
||||||
|
"aboutTelegramChatSubtitle": "Mit anderen Nutzern chatten",
|
||||||
|
"@aboutTelegramChatSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram chat"
|
||||||
|
},
|
||||||
|
"aboutSocial": "Sozial",
|
||||||
|
"@aboutSocial": {
|
||||||
|
"description": "Section for social links"
|
||||||
|
},
|
||||||
"aboutSupport": "Support",
|
"aboutSupport": "Support",
|
||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Spendiere mir einen Kaffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Unterstütze die Entwicklung auf Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
@@ -588,7 +608,7 @@
|
|||||||
"@aboutDabMusicDesc": {
|
"@aboutDabMusicDesc": {
|
||||||
"description": "Credit for DAB Music API"
|
"description": "Credit for DAB Music API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -596,7 +616,7 @@
|
|||||||
"@albumTitle": {
|
"@albumTitle": {
|
||||||
"description": "Album screen title"
|
"description": "Album screen title"
|
||||||
},
|
},
|
||||||
"albumTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
"albumTracks": "{count, plural,=1{1 Song} other{{count} Songs}}",
|
||||||
"@albumTracks": {
|
"@albumTracks": {
|
||||||
"description": "Album track count",
|
"description": "Album track count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -605,11 +625,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"albumDownloadAll": "Download All",
|
"albumDownloadAll": "Alle Herunterladen",
|
||||||
"@albumDownloadAll": {
|
"@albumDownloadAll": {
|
||||||
"description": "Button to download all tracks"
|
"description": "Button to download all tracks"
|
||||||
},
|
},
|
||||||
"albumDownloadRemaining": "Download Remaining",
|
"albumDownloadRemaining": "Downloads verbleibend",
|
||||||
"@albumDownloadRemaining": {
|
"@albumDownloadRemaining": {
|
||||||
"description": "Button to download remaining tracks"
|
"description": "Button to download remaining tracks"
|
||||||
},
|
},
|
||||||
@@ -617,11 +637,11 @@
|
|||||||
"@playlistTitle": {
|
"@playlistTitle": {
|
||||||
"description": "Playlist screen title"
|
"description": "Playlist screen title"
|
||||||
},
|
},
|
||||||
"artistTitle": "Artist",
|
"artistTitle": "Künstler",
|
||||||
"@artistTitle": {
|
"@artistTitle": {
|
||||||
"description": "Artist screen title"
|
"description": "Artist screen title"
|
||||||
},
|
},
|
||||||
"artistAlbums": "Albums",
|
"artistAlbums": "Alben",
|
||||||
"@artistAlbums": {
|
"@artistAlbums": {
|
||||||
"description": "Section header for artist albums"
|
"description": "Section header for artist albums"
|
||||||
},
|
},
|
||||||
@@ -629,11 +649,11 @@
|
|||||||
"@artistSingles": {
|
"@artistSingles": {
|
||||||
"description": "Section header for singles/EPs"
|
"description": "Section header for singles/EPs"
|
||||||
},
|
},
|
||||||
"artistCompilations": "Compilations",
|
"artistCompilations": "Zusammenstellungen",
|
||||||
"@artistCompilations": {
|
"@artistCompilations": {
|
||||||
"description": "Section header for compilations"
|
"description": "Section header for compilations"
|
||||||
},
|
},
|
||||||
"artistReleases": "{count, plural, =1{1 release} other{{count} releases}}",
|
"artistReleases": "{count, plural,=1{1 Veröffentlichung} other{{count} Veröffentlichungen}}",
|
||||||
"@artistReleases": {
|
"@artistReleases": {
|
||||||
"description": "Artist release count",
|
"description": "Artist release count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -642,11 +662,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"artistPopular": "Popular",
|
"artistPopular": "Beliebt",
|
||||||
"@artistPopular": {
|
"@artistPopular": {
|
||||||
"description": "Section header for popular/top tracks"
|
"description": "Section header for popular/top tracks"
|
||||||
},
|
},
|
||||||
"artistMonthlyListeners": "{count} monthly listeners",
|
"artistMonthlyListeners": "{count} monatliche Hörer",
|
||||||
"@artistMonthlyListeners": {
|
"@artistMonthlyListeners": {
|
||||||
"description": "Monthly listener count display",
|
"description": "Monthly listener count display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -656,11 +676,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Titel Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
},
|
},
|
||||||
"trackMetadataArtist": "Artist",
|
"trackMetadataArtist": "Künstler",
|
||||||
"@trackMetadataArtist": {
|
"@trackMetadataArtist": {
|
||||||
"description": "Metadata field - artist name"
|
"description": "Metadata field - artist name"
|
||||||
},
|
},
|
||||||
@@ -668,111 +688,111 @@
|
|||||||
"@trackMetadataAlbum": {
|
"@trackMetadataAlbum": {
|
||||||
"description": "Metadata field - album name"
|
"description": "Metadata field - album name"
|
||||||
},
|
},
|
||||||
"trackMetadataDuration": "Duration",
|
"trackMetadataDuration": "Länge",
|
||||||
"@trackMetadataDuration": {
|
"@trackMetadataDuration": {
|
||||||
"description": "Metadata field - track length"
|
"description": "Metadata field - track length"
|
||||||
},
|
},
|
||||||
"trackMetadataQuality": "Quality",
|
"trackMetadataQuality": "Qualität",
|
||||||
"@trackMetadataQuality": {
|
"@trackMetadataQuality": {
|
||||||
"description": "Metadata field - audio quality"
|
"description": "Metadata field - audio quality"
|
||||||
},
|
},
|
||||||
"trackMetadataPath": "File Path",
|
"trackMetadataPath": "Dateipfad",
|
||||||
"@trackMetadataPath": {
|
"@trackMetadataPath": {
|
||||||
"description": "Metadata field - file location"
|
"description": "Metadata field - file location"
|
||||||
},
|
},
|
||||||
"trackMetadataDownloadedAt": "Downloaded",
|
"trackMetadataDownloadedAt": "Heruntergeladen",
|
||||||
"@trackMetadataDownloadedAt": {
|
"@trackMetadataDownloadedAt": {
|
||||||
"description": "Metadata field - download date"
|
"description": "Metadata field - download date"
|
||||||
},
|
},
|
||||||
"trackMetadataService": "Service",
|
"trackMetadataService": "Anbieter",
|
||||||
"@trackMetadataService": {
|
"@trackMetadataService": {
|
||||||
"description": "Metadata field - download service used"
|
"description": "Metadata field - download service used"
|
||||||
},
|
},
|
||||||
"trackMetadataPlay": "Play",
|
"trackMetadataPlay": "Abspielen",
|
||||||
"@trackMetadataPlay": {
|
"@trackMetadataPlay": {
|
||||||
"description": "Action button - play track"
|
"description": "Action button - play track"
|
||||||
},
|
},
|
||||||
"trackMetadataShare": "Share",
|
"trackMetadataShare": "Teilen",
|
||||||
"@trackMetadataShare": {
|
"@trackMetadataShare": {
|
||||||
"description": "Action button - share track"
|
"description": "Action button - share track"
|
||||||
},
|
},
|
||||||
"trackMetadataDelete": "Delete",
|
"trackMetadataDelete": "Löschen",
|
||||||
"@trackMetadataDelete": {
|
"@trackMetadataDelete": {
|
||||||
"description": "Action button - delete track"
|
"description": "Action button - delete track"
|
||||||
},
|
},
|
||||||
"trackMetadataRedownload": "Re-download",
|
"trackMetadataRedownload": "Erneut herunterladen",
|
||||||
"@trackMetadataRedownload": {
|
"@trackMetadataRedownload": {
|
||||||
"description": "Action button - download again"
|
"description": "Action button - download again"
|
||||||
},
|
},
|
||||||
"trackMetadataOpenFolder": "Open Folder",
|
"trackMetadataOpenFolder": "Ordner öffnen",
|
||||||
"@trackMetadataOpenFolder": {
|
"@trackMetadataOpenFolder": {
|
||||||
"description": "Action button - open containing folder"
|
"description": "Action button - open containing folder"
|
||||||
},
|
},
|
||||||
"setupTitle": "Welcome to SpotiFLAC",
|
"setupTitle": "Willkommen bei SpotiFLAC",
|
||||||
"@setupTitle": {
|
"@setupTitle": {
|
||||||
"description": "Setup wizard title"
|
"description": "Setup wizard title"
|
||||||
},
|
},
|
||||||
"setupSubtitle": "Let's get you started",
|
"setupSubtitle": "Los geht's",
|
||||||
"@setupSubtitle": {
|
"@setupSubtitle": {
|
||||||
"description": "Setup wizard subtitle"
|
"description": "Setup wizard subtitle"
|
||||||
},
|
},
|
||||||
"setupStoragePermission": "Storage Permission",
|
"setupStoragePermission": "Speicherberechtigung",
|
||||||
"@setupStoragePermission": {
|
"@setupStoragePermission": {
|
||||||
"description": "Storage permission step title"
|
"description": "Storage permission step title"
|
||||||
},
|
},
|
||||||
"setupStoragePermissionSubtitle": "Required to save downloaded files",
|
"setupStoragePermissionSubtitle": "Benötigt um heruntergeladene Dateien zu Speichern",
|
||||||
"@setupStoragePermissionSubtitle": {
|
"@setupStoragePermissionSubtitle": {
|
||||||
"description": "Explanation for storage permission"
|
"description": "Explanation for storage permission"
|
||||||
},
|
},
|
||||||
"setupStoragePermissionGranted": "Permission granted",
|
"setupStoragePermissionGranted": "Berechtigung erteilt",
|
||||||
"@setupStoragePermissionGranted": {
|
"@setupStoragePermissionGranted": {
|
||||||
"description": "Status when permission granted"
|
"description": "Status when permission granted"
|
||||||
},
|
},
|
||||||
"setupStoragePermissionDenied": "Permission denied",
|
"setupStoragePermissionDenied": "Berechtigung verweigert",
|
||||||
"@setupStoragePermissionDenied": {
|
"@setupStoragePermissionDenied": {
|
||||||
"description": "Status when permission denied"
|
"description": "Status when permission denied"
|
||||||
},
|
},
|
||||||
"setupGrantPermission": "Grant Permission",
|
"setupGrantPermission": "Berechtigung erlauben",
|
||||||
"@setupGrantPermission": {
|
"@setupGrantPermission": {
|
||||||
"description": "Button to request permission"
|
"description": "Button to request permission"
|
||||||
},
|
},
|
||||||
"setupDownloadLocation": "Download Location",
|
"setupDownloadLocation": "Speicherort",
|
||||||
"@setupDownloadLocation": {
|
"@setupDownloadLocation": {
|
||||||
"description": "Download folder step title"
|
"description": "Download folder step title"
|
||||||
},
|
},
|
||||||
"setupChooseFolder": "Choose Folder",
|
"setupChooseFolder": "Ordner wählen",
|
||||||
"@setupChooseFolder": {
|
"@setupChooseFolder": {
|
||||||
"description": "Button to pick folder"
|
"description": "Button to pick folder"
|
||||||
},
|
},
|
||||||
"setupContinue": "Continue",
|
"setupContinue": "Fortfahren",
|
||||||
"@setupContinue": {
|
"@setupContinue": {
|
||||||
"description": "Continue to next step button"
|
"description": "Continue to next step button"
|
||||||
},
|
},
|
||||||
"setupSkip": "Skip for now",
|
"setupSkip": "Vorerst überspringen",
|
||||||
"@setupSkip": {
|
"@setupSkip": {
|
||||||
"description": "Skip current step button"
|
"description": "Skip current step button"
|
||||||
},
|
},
|
||||||
"setupStorageAccessRequired": "Storage Access Required",
|
"setupStorageAccessRequired": "Speicherzugriff erforderlich",
|
||||||
"@setupStorageAccessRequired": {
|
"@setupStorageAccessRequired": {
|
||||||
"description": "Title when storage access needed"
|
"description": "Title when storage access needed"
|
||||||
},
|
},
|
||||||
"setupStorageAccessMessage": "SpotiFLAC needs \"All files access\" permission to save music files to your chosen folder.",
|
"setupStorageAccessMessage": "SpotiFLAC benötigt die Berechtigung \"Auf alle Dateien zugreifen\", um Musikdateien in deinen gewählten Ordner zu speichern.",
|
||||||
"@setupStorageAccessMessage": {
|
"@setupStorageAccessMessage": {
|
||||||
"description": "Explanation for storage access"
|
"description": "Explanation for storage access"
|
||||||
},
|
},
|
||||||
"setupStorageAccessMessageAndroid11": "Android 11+ requires \"All files access\" permission to save files to your chosen download folder.",
|
"setupStorageAccessMessageAndroid11": "Android 11+ benötigt die Berechtigung „Auf alle Dateien“, um Dateien im ausgewählten Download-Ordner zu speichern.",
|
||||||
"@setupStorageAccessMessageAndroid11": {
|
"@setupStorageAccessMessageAndroid11": {
|
||||||
"description": "Android 11+ specific explanation"
|
"description": "Android 11+ specific explanation"
|
||||||
},
|
},
|
||||||
"setupOpenSettings": "Open Settings",
|
"setupOpenSettings": "Einstellungen öffnen",
|
||||||
"@setupOpenSettings": {
|
"@setupOpenSettings": {
|
||||||
"description": "Button to open system settings"
|
"description": "Button to open system settings"
|
||||||
},
|
},
|
||||||
"setupPermissionDeniedMessage": "Permission denied. Please grant all permissions to continue.",
|
"setupPermissionDeniedMessage": "Berechtigung verweigert. Bitte erteilen Sie alle Berechtigungen um fortzufahren.",
|
||||||
"@setupPermissionDeniedMessage": {
|
"@setupPermissionDeniedMessage": {
|
||||||
"description": "Error when permission denied"
|
"description": "Error when permission denied"
|
||||||
},
|
},
|
||||||
"setupPermissionRequired": "{permissionType} Permission Required",
|
"setupPermissionRequired": "{permissionType} Zugriff verweigert",
|
||||||
"@setupPermissionRequired": {
|
"@setupPermissionRequired": {
|
||||||
"description": "Generic permission required title",
|
"description": "Generic permission required title",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -782,7 +802,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"setupPermissionRequiredMessage": "{permissionType} permission is required for the best experience. You can change this later in Settings.",
|
"setupPermissionRequiredMessage": "{permissionType} Berechtigung ist erforderlich für\ndie beste Benutzererfahrung. Für kannst dies später in den Einstellungen ändern.",
|
||||||
"@setupPermissionRequiredMessage": {
|
"@setupPermissionRequiredMessage": {
|
||||||
"description": "Generic permission required message",
|
"description": "Generic permission required message",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -791,63 +811,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"setupSelectDownloadFolder": "Select Download Folder",
|
"setupSelectDownloadFolder": "Wähle Download-Ordner aus",
|
||||||
"@setupSelectDownloadFolder": {
|
"@setupSelectDownloadFolder": {
|
||||||
"description": "Folder selection step title"
|
"description": "Folder selection step title"
|
||||||
},
|
},
|
||||||
"setupUseDefaultFolder": "Use Default Folder?",
|
"setupUseDefaultFolder": "Als Standardordner verwenden?",
|
||||||
"@setupUseDefaultFolder": {
|
"@setupUseDefaultFolder": {
|
||||||
"description": "Dialog title for default folder"
|
"description": "Dialog title for default folder"
|
||||||
},
|
},
|
||||||
"setupNoFolderSelected": "No folder selected. Would you like to use the default Music folder?",
|
"setupNoFolderSelected": "Kein Ordner ausgewählt. Soll der Standard-Musikordner verwendet werden?",
|
||||||
"@setupNoFolderSelected": {
|
"@setupNoFolderSelected": {
|
||||||
"description": "Prompt when no folder selected"
|
"description": "Prompt when no folder selected"
|
||||||
},
|
},
|
||||||
"setupUseDefault": "Use Default",
|
"setupUseDefault": "Standart benutzen",
|
||||||
"@setupUseDefault": {
|
"@setupUseDefault": {
|
||||||
"description": "Button to use default folder"
|
"description": "Button to use default folder"
|
||||||
},
|
},
|
||||||
"setupDownloadLocationTitle": "Download Location",
|
"setupDownloadLocationTitle": "Speicherort",
|
||||||
"@setupDownloadLocationTitle": {
|
"@setupDownloadLocationTitle": {
|
||||||
"description": "Download location dialog title"
|
"description": "Download location dialog title"
|
||||||
},
|
},
|
||||||
"setupDownloadLocationIosMessage": "On iOS, downloads are saved to the app's Documents folder. You can access them via the Files app.",
|
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Sie können sie über die Datei-App aufrufen.",
|
||||||
"@setupDownloadLocationIosMessage": {
|
"@setupDownloadLocationIosMessage": {
|
||||||
"description": "iOS-specific folder info"
|
"description": "iOS-specific folder info"
|
||||||
},
|
},
|
||||||
"setupAppDocumentsFolder": "App Documents Folder",
|
"setupAppDocumentsFolder": "App-Dokumentenordner",
|
||||||
"@setupAppDocumentsFolder": {
|
"@setupAppDocumentsFolder": {
|
||||||
"description": "iOS documents folder option"
|
"description": "iOS documents folder option"
|
||||||
},
|
},
|
||||||
"setupAppDocumentsFolderSubtitle": "Recommended - accessible via Files app",
|
"setupAppDocumentsFolderSubtitle": "Empfohlen - zugänglich über die Datei-App",
|
||||||
"@setupAppDocumentsFolderSubtitle": {
|
"@setupAppDocumentsFolderSubtitle": {
|
||||||
"description": "Subtitle for documents folder"
|
"description": "Subtitle for documents folder"
|
||||||
},
|
},
|
||||||
"setupChooseFromFiles": "Choose from Files",
|
"setupChooseFromFiles": "Aus Dateien auswählen",
|
||||||
"@setupChooseFromFiles": {
|
"@setupChooseFromFiles": {
|
||||||
"description": "iOS file picker option"
|
"description": "iOS file picker option"
|
||||||
},
|
},
|
||||||
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
|
"setupChooseFromFilesSubtitle": "Wählen Sie iCloud oder einen anderen Ort",
|
||||||
"@setupChooseFromFilesSubtitle": {
|
"@setupChooseFromFilesSubtitle": {
|
||||||
"description": "Subtitle for file picker"
|
"description": "Subtitle for file picker"
|
||||||
},
|
},
|
||||||
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
|
"setupIosEmptyFolderWarning": "iOS-Einschränkung: Leere Ordner können nicht ausgewählt werden. Wählen Sie einen Ordner mit mindestens einer Datei.",
|
||||||
"@setupIosEmptyFolderWarning": {
|
"@setupIosEmptyFolderWarning": {
|
||||||
"description": "iOS folder selection warning"
|
"description": "iOS folder selection warning"
|
||||||
},
|
},
|
||||||
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
|
"setupDownloadInFlac": "Spotify Titel in FLAC herunterladen",
|
||||||
"@setupDownloadInFlac": {
|
"@setupDownloadInFlac": {
|
||||||
"description": "App tagline in setup"
|
"description": "App tagline in setup"
|
||||||
},
|
},
|
||||||
"setupStepStorage": "Storage",
|
"setupStepStorage": "Speicherort",
|
||||||
"@setupStepStorage": {
|
"@setupStepStorage": {
|
||||||
"description": "Setup step indicator - storage"
|
"description": "Setup step indicator - storage"
|
||||||
},
|
},
|
||||||
"setupStepNotification": "Notification",
|
"setupStepNotification": "Benachrichtigung",
|
||||||
"@setupStepNotification": {
|
"@setupStepNotification": {
|
||||||
"description": "Setup step indicator - notification"
|
"description": "Setup step indicator - notification"
|
||||||
},
|
},
|
||||||
"setupStepFolder": "Folder",
|
"setupStepFolder": "Ordner",
|
||||||
"@setupStepFolder": {
|
"@setupStepFolder": {
|
||||||
"description": "Setup step indicator - folder"
|
"description": "Setup step indicator - folder"
|
||||||
},
|
},
|
||||||
@@ -855,55 +875,55 @@
|
|||||||
"@setupStepSpotify": {
|
"@setupStepSpotify": {
|
||||||
"description": "Setup step indicator - Spotify API"
|
"description": "Setup step indicator - Spotify API"
|
||||||
},
|
},
|
||||||
"setupStepPermission": "Permission",
|
"setupStepPermission": "Berechtigung",
|
||||||
"@setupStepPermission": {
|
"@setupStepPermission": {
|
||||||
"description": "Setup step indicator - permission"
|
"description": "Setup step indicator - permission"
|
||||||
},
|
},
|
||||||
"setupStorageGranted": "Storage Permission Granted!",
|
"setupStorageGranted": "Speicherberechtigung erlaubt!",
|
||||||
"@setupStorageGranted": {
|
"@setupStorageGranted": {
|
||||||
"description": "Success message for storage permission"
|
"description": "Success message for storage permission"
|
||||||
},
|
},
|
||||||
"setupStorageRequired": "Storage Permission Required",
|
"setupStorageRequired": "Speicherzugriff erforderlich",
|
||||||
"@setupStorageRequired": {
|
"@setupStorageRequired": {
|
||||||
"description": "Title when storage permission needed"
|
"description": "Title when storage permission needed"
|
||||||
},
|
},
|
||||||
"setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.",
|
"setupStorageDescription": "SpotiFLAC benötigt Speicherrechte, um die heruntergeladenen Musikdateien zu speichern.",
|
||||||
"@setupStorageDescription": {
|
"@setupStorageDescription": {
|
||||||
"description": "Explanation for storage permission"
|
"description": "Explanation for storage permission"
|
||||||
},
|
},
|
||||||
"setupNotificationGranted": "Notification Permission Granted!",
|
"setupNotificationGranted": "Benachrichtigungs-Berechtigung erteilt",
|
||||||
"@setupNotificationGranted": {
|
"@setupNotificationGranted": {
|
||||||
"description": "Success message for notification permission"
|
"description": "Success message for notification permission"
|
||||||
},
|
},
|
||||||
"setupNotificationEnable": "Enable Notifications",
|
"setupNotificationEnable": "Benachrichtigungen aktivieren",
|
||||||
"@setupNotificationEnable": {
|
"@setupNotificationEnable": {
|
||||||
"description": "Button to enable notifications"
|
"description": "Button to enable notifications"
|
||||||
},
|
},
|
||||||
"setupNotificationDescription": "Get notified when downloads complete or require attention.",
|
"setupNotificationDescription": "Benachrichtigt werden, wenn Downloads abgeschlossen sind.",
|
||||||
"@setupNotificationDescription": {
|
"@setupNotificationDescription": {
|
||||||
"description": "Explanation for notifications"
|
"description": "Explanation for notifications"
|
||||||
},
|
},
|
||||||
"setupFolderSelected": "Download Folder Selected!",
|
"setupFolderSelected": "Download Ordner ausgewählt!",
|
||||||
"@setupFolderSelected": {
|
"@setupFolderSelected": {
|
||||||
"description": "Success message for folder selection"
|
"description": "Success message for folder selection"
|
||||||
},
|
},
|
||||||
"setupFolderChoose": "Choose Download Folder",
|
"setupFolderChoose": "Speicherort auwählen",
|
||||||
"@setupFolderChoose": {
|
"@setupFolderChoose": {
|
||||||
"description": "Button to choose folder"
|
"description": "Button to choose folder"
|
||||||
},
|
},
|
||||||
"setupFolderDescription": "Select a folder where your downloaded music will be saved.",
|
"setupFolderDescription": "Wählen Sie einen Ordner, in dem Ihre heruntergeladene Musik gespeichert wird.",
|
||||||
"@setupFolderDescription": {
|
"@setupFolderDescription": {
|
||||||
"description": "Explanation for folder selection"
|
"description": "Explanation for folder selection"
|
||||||
},
|
},
|
||||||
"setupChangeFolder": "Change Folder",
|
"setupChangeFolder": "Ordner ändern",
|
||||||
"@setupChangeFolder": {
|
"@setupChangeFolder": {
|
||||||
"description": "Button to change selected folder"
|
"description": "Button to change selected folder"
|
||||||
},
|
},
|
||||||
"setupSelectFolder": "Select Folder",
|
"setupSelectFolder": "Ordner wählen",
|
||||||
"@setupSelectFolder": {
|
"@setupSelectFolder": {
|
||||||
"description": "Button to select folder"
|
"description": "Button to select folder"
|
||||||
},
|
},
|
||||||
"setupSpotifyApiOptional": "Spotify API (Optional)",
|
"setupSpotifyApiOptional": "Spotify-API (optional)",
|
||||||
"@setupSpotifyApiOptional": {
|
"@setupSpotifyApiOptional": {
|
||||||
"description": "Spotify API step title"
|
"description": "Spotify API step title"
|
||||||
},
|
},
|
||||||
@@ -1122,6 +1142,15 @@
|
|||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||||
|
"csvImportTracks": "{count} tracks from CSV",
|
||||||
|
"@csvImportTracks": {
|
||||||
|
"description": "Label shown in quality picker for CSV import",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@dialogImportPlaylistMessage": {
|
"@dialogImportPlaylistMessage": {
|
||||||
"description": "Dialog message - import playlist confirmation",
|
"description": "Dialog message - import playlist confirmation",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1851,6 +1880,42 @@
|
|||||||
"@sectionFileSettings": {
|
"@sectionFileSettings": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
|
"sectionLyrics": "Lyrics",
|
||||||
|
"@sectionLyrics": {
|
||||||
|
"description": "Settings section header"
|
||||||
|
},
|
||||||
|
"lyricsMode": "Lyrics Mode",
|
||||||
|
"@lyricsMode": {
|
||||||
|
"description": "Setting - how to save lyrics"
|
||||||
|
},
|
||||||
|
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||||
|
"@lyricsModeDescription": {
|
||||||
|
"description": "Lyrics mode picker description"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbed": "Embed in file",
|
||||||
|
"@lyricsModeEmbed": {
|
||||||
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||||
|
"@lyricsModeEmbedSubtitle": {
|
||||||
|
"description": "Subtitle for embed option"
|
||||||
|
},
|
||||||
|
"lyricsModeExternal": "External .lrc file",
|
||||||
|
"@lyricsModeExternal": {
|
||||||
|
"description": "Lyrics mode option - separate LRC file"
|
||||||
|
},
|
||||||
|
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||||
|
"@lyricsModeExternalSubtitle": {
|
||||||
|
"description": "Subtitle for external option"
|
||||||
|
},
|
||||||
|
"lyricsModeBoth": "Both",
|
||||||
|
"@lyricsModeBoth": {
|
||||||
|
"description": "Lyrics mode option - embed and external"
|
||||||
|
},
|
||||||
|
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||||
|
"@lyricsModeBothSubtitle": {
|
||||||
|
"description": "Subtitle for both option"
|
||||||
|
},
|
||||||
"sectionColor": "Color",
|
"sectionColor": "Color",
|
||||||
"@sectionColor": {
|
"@sectionColor": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
@@ -1997,6 +2062,18 @@
|
|||||||
"@trackReleaseDate": {
|
"@trackReleaseDate": {
|
||||||
"description": "Metadata label - release date"
|
"description": "Metadata label - release date"
|
||||||
},
|
},
|
||||||
|
"trackGenre": "Genre",
|
||||||
|
"@trackGenre": {
|
||||||
|
"description": "Metadata label - music genre"
|
||||||
|
},
|
||||||
|
"trackLabel": "Label",
|
||||||
|
"@trackLabel": {
|
||||||
|
"description": "Metadata label - record label"
|
||||||
|
},
|
||||||
|
"trackCopyright": "Copyright",
|
||||||
|
"@trackCopyright": {
|
||||||
|
"description": "Metadata label - copyright information"
|
||||||
|
},
|
||||||
"trackDownloaded": "Downloaded",
|
"trackDownloaded": "Downloaded",
|
||||||
"@trackDownloaded": {
|
"@trackDownloaded": {
|
||||||
"description": "Metadata label - download date"
|
"description": "Metadata label - download date"
|
||||||
@@ -2017,6 +2094,18 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
|
"trackEmbedLyrics": "Embed Lyrics",
|
||||||
|
"@trackEmbedLyrics": {
|
||||||
|
"description": "Action - embed lyrics into audio file"
|
||||||
|
},
|
||||||
|
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||||
|
"@trackLyricsEmbedded": {
|
||||||
|
"description": "Snackbar - lyrics saved to file"
|
||||||
|
},
|
||||||
|
"trackInstrumental": "Instrumental track",
|
||||||
|
"@trackInstrumental": {
|
||||||
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
|
},
|
||||||
"trackCopiedToClipboard": "Copied to clipboard",
|
"trackCopiedToClipboard": "Copied to clipboard",
|
||||||
"@trackCopiedToClipboard": {
|
"@trackCopiedToClipboard": {
|
||||||
"description": "Snackbar - content copied"
|
"description": "Snackbar - content copied"
|
||||||
@@ -2328,6 +2417,26 @@
|
|||||||
"@qualityHiResFlacMaxSubtitle": {
|
"@qualityHiResFlacMaxSubtitle": {
|
||||||
"description": "Technical spec for hi-res max"
|
"description": "Technical spec for hi-res max"
|
||||||
},
|
},
|
||||||
|
"qualityMp3": "MP3",
|
||||||
|
"@qualityMp3": {
|
||||||
|
"description": "Quality option - MP3 lossy format"
|
||||||
|
},
|
||||||
|
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||||
|
"@qualityMp3Subtitle": {
|
||||||
|
"description": "Technical spec for MP3"
|
||||||
|
},
|
||||||
|
"enableMp3Option": "Enable MP3 Option",
|
||||||
|
"@enableMp3Option": {
|
||||||
|
"description": "Setting - enable MP3 quality option"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||||
|
"@enableMp3OptionSubtitleOn": {
|
||||||
|
"description": "Subtitle when MP3 is enabled"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||||
|
"@enableMp3OptionSubtitleOff": {
|
||||||
|
"description": "Subtitle when MP3 is disabled"
|
||||||
|
},
|
||||||
"qualityNote": "Actual quality depends on track availability from the service",
|
"qualityNote": "Actual quality depends on track availability from the service",
|
||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
@@ -2516,6 +2625,14 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
|
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||||
|
"@albumFolderArtistAlbumSingles": {
|
||||||
|
"description": "Album folder option with singles inside artist"
|
||||||
|
},
|
||||||
|
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||||
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
|
"description": "Folder structure example"
|
||||||
|
},
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
@@ -2572,6 +2689,16 @@
|
|||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
|
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||||
|
"@downloadedAlbumDiscHeader": {
|
||||||
|
"description": "Header for disc separator in multi-disc albums",
|
||||||
|
"placeholders": {
|
||||||
|
"discNumber": {
|
||||||
|
"type": "int",
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"utilityFunctions": "Utility Functions",
|
"utilityFunctions": "Utility Functions",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
@@ -2611,5 +2738,123 @@
|
|||||||
"description": "Error message"
|
"description": "Error message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"discographyDownload": "Download Discography",
|
||||||
|
"@discographyDownload": {
|
||||||
|
"description": "Button - download artist discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAll": "Download All",
|
||||||
|
"@discographyDownloadAll": {
|
||||||
|
"description": "Option - download entire discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||||
|
"@discographyDownloadAllSubtitle": {
|
||||||
|
"description": "Subtitle showing total tracks and albums",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnly": "Albums Only",
|
||||||
|
"@discographyAlbumsOnly": {
|
||||||
|
"description": "Option - download only albums"
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||||
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing album tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySinglesOnly": "Singles & EPs Only",
|
||||||
|
"@discographySinglesOnly": {
|
||||||
|
"description": "Option - download only singles"
|
||||||
|
},
|
||||||
|
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||||
|
"@discographySinglesOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing singles tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectAlbums": "Select Albums...",
|
||||||
|
"@discographySelectAlbums": {
|
||||||
|
"description": "Option - manually select albums to download"
|
||||||
|
},
|
||||||
|
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||||
|
"@discographySelectAlbumsSubtitle": {
|
||||||
|
"description": "Subtitle for select albums option"
|
||||||
|
},
|
||||||
|
"discographyFetchingTracks": "Fetching tracks...",
|
||||||
|
"@discographyFetchingTracks": {
|
||||||
|
"description": "Progress - fetching album tracks"
|
||||||
|
},
|
||||||
|
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||||
|
"@discographyFetchingAlbum": {
|
||||||
|
"description": "Progress - fetching specific album",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectedCount": "{count} selected",
|
||||||
|
"@discographySelectedCount": {
|
||||||
|
"description": "Selection count badge",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyDownloadSelected": "Download Selected",
|
||||||
|
"@discographyDownloadSelected": {
|
||||||
|
"description": "Button - download selected albums"
|
||||||
|
},
|
||||||
|
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||||
|
"@discographyAddedToQueue": {
|
||||||
|
"description": "Snackbar - tracks added from discography",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||||
|
"@discographySkippedDownloaded": {
|
||||||
|
"description": "Snackbar - with skipped tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"added": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"skipped": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyNoAlbums": "No albums available",
|
||||||
|
"@discographyNoAlbums": {
|
||||||
|
"description": "Error - no albums found for artist"
|
||||||
|
},
|
||||||
|
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||||
|
"@discographyFailedToFetch": {
|
||||||
|
"description": "Error - some albums failed to load"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,10 @@
|
|||||||
|
|
||||||
"navHome": "Home",
|
"navHome": "Home",
|
||||||
"@navHome": {"description": "Bottom navigation - Home tab"},
|
"@navHome": {"description": "Bottom navigation - Home tab"},
|
||||||
|
"navLibrary": "Library",
|
||||||
|
"@navLibrary": {"description": "Bottom navigation - Library tab"},
|
||||||
"navHistory": "History",
|
"navHistory": "History",
|
||||||
"@navHistory": {"description": "Bottom navigation - History tab"},
|
"@navHistory": {"description": "Bottom navigation - History tab (legacy)"},
|
||||||
"navSettings": "Settings",
|
"navSettings": "Settings",
|
||||||
"@navSettings": {"description": "Bottom navigation - Settings tab"},
|
"@navSettings": {"description": "Bottom navigation - Settings tab"},
|
||||||
"navStore": "Store",
|
"navStore": "Store",
|
||||||
@@ -75,8 +77,10 @@
|
|||||||
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
|
"@historyNoAlbumsSubtitle": {"description": "Empty state subtitle for albums filter"},
|
||||||
"historyNoSingles": "No single downloads",
|
"historyNoSingles": "No single downloads",
|
||||||
"@historyNoSingles": {"description": "Empty state when filtering singles"},
|
"@historyNoSingles": {"description": "Empty state when filtering singles"},
|
||||||
"historyNoSinglesSubtitle": "Single track downloads will appear here",
|
"historyNoSinglesSubtitle": "Single track downloads will appear here",
|
||||||
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
|
"@historyNoSinglesSubtitle": {"description": "Empty state subtitle for singles filter"},
|
||||||
|
"historySearchHint": "Search history...",
|
||||||
|
"@historySearchHint": {"description": "Search bar placeholder in history"},
|
||||||
|
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"@settingsTitle": {"description": "Settings screen title"},
|
"@settingsTitle": {"description": "Settings screen title"},
|
||||||
@@ -237,6 +241,8 @@
|
|||||||
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
|
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
|
||||||
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
|
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
|
||||||
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
|
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
|
||||||
|
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
|
||||||
|
"@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"},
|
||||||
|
|
||||||
"extensionsTitle": "Extensions",
|
"extensionsTitle": "Extensions",
|
||||||
"@extensionsTitle": {"description": "Extensions page title"},
|
"@extensionsTitle": {"description": "Extensions page title"},
|
||||||
@@ -304,16 +310,22 @@
|
|||||||
"@aboutReportIssue": {"description": "Link to report bugs"},
|
"@aboutReportIssue": {"description": "Link to report bugs"},
|
||||||
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
||||||
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
|
"@aboutReportIssueSubtitle": {"description": "Subtitle for report issue"},
|
||||||
"aboutFeatureRequest": "Feature request",
|
"aboutFeatureRequest": "Feature request",
|
||||||
"@aboutFeatureRequest": {"description": "Link to suggest features"},
|
"@aboutFeatureRequest": {"description": "Link to suggest features"},
|
||||||
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
|
"aboutFeatureRequestSubtitle": "Suggest new features for the app",
|
||||||
"@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"},
|
"@aboutFeatureRequestSubtitle": {"description": "Subtitle for feature request"},
|
||||||
|
"aboutTelegramChannel": "Telegram Channel",
|
||||||
|
"@aboutTelegramChannel": {"description": "Link to Telegram channel"},
|
||||||
|
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||||
|
"@aboutTelegramChannelSubtitle": {"description": "Subtitle for Telegram channel"},
|
||||||
|
"aboutTelegramChat": "Telegram Community",
|
||||||
|
"@aboutTelegramChat": {"description": "Link to Telegram chat group"},
|
||||||
|
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||||
|
"@aboutTelegramChatSubtitle": {"description": "Subtitle for Telegram chat"},
|
||||||
|
"aboutSocial": "Social",
|
||||||
|
"@aboutSocial": {"description": "Section for social links"},
|
||||||
"aboutSupport": "Support",
|
"aboutSupport": "Support",
|
||||||
"@aboutSupport": {"description": "Section for support/donation links"},
|
"@aboutSupport": {"description": "Section for support/donation links"},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {"description": "Donation link"},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {"description": "Subtitle for donation"},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {"description": "Section for app info"},
|
"@aboutApp": {"description": "Section for app info"},
|
||||||
"aboutVersion": "Version",
|
"aboutVersion": "Version",
|
||||||
@@ -322,6 +334,8 @@
|
|||||||
"@aboutBinimumDesc": {"description": "Credit description for binimum"},
|
"@aboutBinimumDesc": {"description": "Credit description for binimum"},
|
||||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
||||||
"@aboutSachinsenalDesc": {"description": "Credit description for sachinsenal0x64"},
|
"@aboutSachinsenalDesc": {"description": "Credit description for sachinsenal0x64"},
|
||||||
|
"aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!",
|
||||||
|
"@aboutSjdonadoDesc": {"description": "Credit description for sjdonado"},
|
||||||
"aboutDoubleDouble": "DoubleDouble",
|
"aboutDoubleDouble": "DoubleDouble",
|
||||||
"@aboutDoubleDouble": {"description": "Name of Amazon API service - DO NOT TRANSLATE"},
|
"@aboutDoubleDouble": {"description": "Name of Amazon API service - DO NOT TRANSLATE"},
|
||||||
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
|
"aboutDoubleDoubleDesc": "Amazing API for Amazon Music downloads. Thank you for making it free!",
|
||||||
@@ -330,6 +344,10 @@
|
|||||||
"@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"},
|
"@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"},
|
||||||
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
|
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
|
||||||
"@aboutDabMusicDesc": {"description": "Credit for DAB Music API"},
|
"@aboutDabMusicDesc": {"description": "Credit for DAB Music API"},
|
||||||
|
"aboutSpotiSaver": "SpotiSaver",
|
||||||
|
"@aboutSpotiSaver": {"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"},
|
||||||
|
"aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!",
|
||||||
|
"@aboutSpotiSaverDesc": {"description": "Credit for SpotiSaver API"},
|
||||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||||
"@aboutAppDescription": {"description": "App description in header card"},
|
"@aboutAppDescription": {"description": "App description in header card"},
|
||||||
|
|
||||||
@@ -467,8 +485,10 @@
|
|||||||
"@setupChooseFromFiles": {"description": "iOS file picker option"},
|
"@setupChooseFromFiles": {"description": "iOS file picker option"},
|
||||||
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
|
"setupChooseFromFilesSubtitle": "Select iCloud or other location",
|
||||||
"@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"},
|
"@setupChooseFromFilesSubtitle": {"description": "Subtitle for file picker"},
|
||||||
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
|
"setupIosEmptyFolderWarning": "iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.",
|
||||||
"@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"},
|
"@setupIosEmptyFolderWarning": {"description": "iOS folder selection warning"},
|
||||||
|
"setupIcloudNotSupported": "iCloud Drive is not supported. Please use the app Documents folder.",
|
||||||
|
"@setupIcloudNotSupported": {"description": "Error when user selects iCloud Drive on iOS"},
|
||||||
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
|
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
|
||||||
"@setupDownloadInFlac": {"description": "App tagline in setup"},
|
"@setupDownloadInFlac": {"description": "App tagline in setup"},
|
||||||
"setupStepStorage": "Storage",
|
"setupStepStorage": "Storage",
|
||||||
@@ -654,6 +674,13 @@
|
|||||||
"trackName": {"type": "String"}
|
"trackName": {"type": "String"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"snackbarAlreadyInLibrary": "\"{trackName}\" already exists in your library",
|
||||||
|
"@snackbarAlreadyInLibrary": {
|
||||||
|
"description": "Snackbar - track already exists in local library",
|
||||||
|
"placeholders": {
|
||||||
|
"trackName": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"snackbarHistoryCleared": "History cleared",
|
"snackbarHistoryCleared": "History cleared",
|
||||||
"@snackbarHistoryCleared": {"description": "Snackbar - history deleted"},
|
"@snackbarHistoryCleared": {"description": "Snackbar - history deleted"},
|
||||||
"snackbarCredentialsSaved": "Credentials saved",
|
"snackbarCredentialsSaved": "Credentials saved",
|
||||||
@@ -1176,6 +1203,12 @@
|
|||||||
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
|
"@trackLyricsTimeout": {"description": "Message when lyrics request times out"},
|
||||||
"trackLyricsLoadFailed": "Failed to load lyrics",
|
"trackLyricsLoadFailed": "Failed to load lyrics",
|
||||||
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
|
"@trackLyricsLoadFailed": {"description": "Message when lyrics loading fails"},
|
||||||
|
"trackEmbedLyrics": "Embed Lyrics",
|
||||||
|
"@trackEmbedLyrics": {"description": "Action - embed lyrics into audio file"},
|
||||||
|
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||||
|
"@trackLyricsEmbedded": {"description": "Snackbar - lyrics saved to file"},
|
||||||
|
"trackInstrumental": "Instrumental track",
|
||||||
|
"@trackInstrumental": {"description": "Message when track is instrumental (no lyrics)"},
|
||||||
"trackCopiedToClipboard": "Copied to clipboard",
|
"trackCopiedToClipboard": "Copied to clipboard",
|
||||||
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
|
"@trackCopiedToClipboard": {"description": "Snackbar - content copied"},
|
||||||
"trackDeleteConfirmTitle": "Remove from device?",
|
"trackDeleteConfirmTitle": "Remove from device?",
|
||||||
@@ -1355,18 +1388,30 @@
|
|||||||
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
|
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
|
||||||
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
|
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
|
||||||
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
|
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
|
||||||
"qualityMp3": "MP3",
|
"qualityLossy": "Lossy",
|
||||||
"@qualityMp3": {"description": "Quality option - MP3 lossy format"},
|
"@qualityLossy": {"description": "Quality option - lossy format (MP3/Opus)"},
|
||||||
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
"qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)",
|
||||||
"@qualityMp3Subtitle": {"description": "Technical spec for MP3"},
|
"@qualityLossyMp3Subtitle": {"description": "Technical spec for lossy MP3"},
|
||||||
"enableMp3Option": "Enable MP3 Option",
|
"qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)",
|
||||||
"@enableMp3Option": {"description": "Setting - enable MP3 quality option"},
|
"@qualityLossyOpusSubtitle": {"description": "Technical spec for lossy Opus"},
|
||||||
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
"enableLossyOption": "Enable Lossy Option",
|
||||||
"@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"},
|
"@enableLossyOption": {"description": "Setting - enable lossy quality option"},
|
||||||
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
"enableLossyOptionSubtitleOn": "Lossy quality option is available",
|
||||||
"@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"},
|
"@enableLossyOptionSubtitleOn": {"description": "Subtitle when lossy is enabled"},
|
||||||
|
"enableLossyOptionSubtitleOff": "Downloads FLAC then converts to lossy format",
|
||||||
|
"@enableLossyOptionSubtitleOff": {"description": "Subtitle when lossy is disabled"},
|
||||||
|
"lossyFormat": "Lossy Format",
|
||||||
|
"@lossyFormat": {"description": "Setting - choose lossy format"},
|
||||||
|
"lossyFormatDescription": "Choose the lossy format for conversion",
|
||||||
|
"@lossyFormatDescription": {"description": "Description for lossy format picker"},
|
||||||
|
"lossyFormatMp3Subtitle": "320kbps, best compatibility",
|
||||||
|
"@lossyFormatMp3Subtitle": {"description": "MP3 format description"},
|
||||||
|
"lossyFormatOpusSubtitle": "128kbps, better quality at smaller size",
|
||||||
|
"@lossyFormatOpusSubtitle": {"description": "Opus format description"},
|
||||||
"qualityNote": "Actual quality depends on track availability from the service",
|
"qualityNote": "Actual quality depends on track availability from the service",
|
||||||
"@qualityNote": {"description": "Note about quality availability"},
|
"@qualityNote": {"description": "Note about quality availability"},
|
||||||
|
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||||
|
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
|
||||||
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
|
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
|
||||||
@@ -1376,6 +1421,18 @@
|
|||||||
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
|
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
|
||||||
"downloadAlbumFolderStructure": "Album Folder Structure",
|
"downloadAlbumFolderStructure": "Album Folder Structure",
|
||||||
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
|
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
|
||||||
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
|
"@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"},
|
||||||
|
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
|
||||||
|
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
|
||||||
|
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||||
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
|
||||||
|
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||||
|
"@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"},
|
||||||
|
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||||
|
"@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"},
|
||||||
|
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||||
|
"@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"},
|
||||||
"downloadSaveFormat": "Save Format",
|
"downloadSaveFormat": "Save Format",
|
||||||
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
"@downloadSaveFormat": {"description": "Setting - output file format"},
|
||||||
"downloadSelectService": "Select Service",
|
"downloadSelectService": "Select Service",
|
||||||
@@ -1428,10 +1485,32 @@
|
|||||||
|
|
||||||
"queueTitle": "Download Queue",
|
"queueTitle": "Download Queue",
|
||||||
"@queueTitle": {"description": "Queue screen title"},
|
"@queueTitle": {"description": "Queue screen title"},
|
||||||
"queueClearAll": "Clear All",
|
"queueClearAll": "Clear All",
|
||||||
"@queueClearAll": {"description": "Button - clear all queue items"},
|
"@queueClearAll": {"description": "Button - clear all queue items"},
|
||||||
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
|
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
|
||||||
"@queueClearAllMessage": {"description": "Clear queue confirmation"},
|
"@queueClearAllMessage": {"description": "Clear queue confirmation"},
|
||||||
|
"queueExportFailed": "Export",
|
||||||
|
"@queueExportFailed": {"description": "Button - export failed downloads to TXT"},
|
||||||
|
"queueExportFailedSuccess": "Failed downloads exported to TXT file",
|
||||||
|
"@queueExportFailedSuccess": {"description": "Success message after exporting failed downloads"},
|
||||||
|
"queueExportFailedClear": "Clear Failed",
|
||||||
|
"@queueExportFailedClear": {"description": "Action to clear failed downloads after export"},
|
||||||
|
"queueExportFailedError": "Failed to export downloads",
|
||||||
|
"@queueExportFailedError": {"description": "Error message when export fails"},
|
||||||
|
"settingsAutoExportFailed": "Auto-export failed downloads",
|
||||||
|
"@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"},
|
||||||
|
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
|
||||||
|
"@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"},
|
||||||
|
|
||||||
|
"settingsDownloadNetwork": "Download Network",
|
||||||
|
"@settingsDownloadNetwork": {"description": "Setting for network type preference"},
|
||||||
|
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
|
||||||
|
"@settingsDownloadNetworkAny": {"description": "Network option - use any connection"},
|
||||||
|
"settingsDownloadNetworkWifiOnly": "WiFi Only",
|
||||||
|
"@settingsDownloadNetworkWifiOnly": {"description": "Network option - only use WiFi"},
|
||||||
|
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
|
||||||
|
"@settingsDownloadNetworkSubtitle": {"description": "Subtitle explaining network preference"},
|
||||||
|
|
||||||
"queueEmpty": "No downloads in queue",
|
"queueEmpty": "No downloads in queue",
|
||||||
"@queueEmpty": {"description": "Empty queue state title"},
|
"@queueEmpty": {"description": "Empty queue state title"},
|
||||||
"queueEmptySubtitle": "Add tracks from the home screen",
|
"queueEmptySubtitle": "Add tracks from the home screen",
|
||||||
@@ -1465,6 +1544,10 @@
|
|||||||
"@albumFolderYearAlbum": {"description": "Album folder option with year"},
|
"@albumFolderYearAlbum": {"description": "Album folder option with year"},
|
||||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
||||||
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
|
"@albumFolderYearAlbumSubtitle": {"description": "Folder structure example"},
|
||||||
|
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||||
|
"@albumFolderArtistAlbumSingles": {"description": "Album folder option with singles inside artist"},
|
||||||
|
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||||
|
"@albumFolderArtistAlbumSinglesSubtitle": {"description": "Folder structure example"},
|
||||||
|
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
|
"@downloadedAlbumDeleteSelected": {"description": "Button - delete selected tracks"},
|
||||||
@@ -1523,6 +1606,12 @@
|
|||||||
"@recentTypeSong": {"description": "Recent access item type - song/track"},
|
"@recentTypeSong": {"description": "Recent access item type - song/track"},
|
||||||
"recentTypePlaylist": "Playlist",
|
"recentTypePlaylist": "Playlist",
|
||||||
"@recentTypePlaylist": {"description": "Recent access item type - playlist"},
|
"@recentTypePlaylist": {"description": "Recent access item type - playlist"},
|
||||||
|
"recentEmpty": "No recent items yet",
|
||||||
|
"@recentEmpty": {"description": "Empty state text for recent access list"},
|
||||||
|
"recentShowAllDownloads": "Show All Downloads",
|
||||||
|
"@recentShowAllDownloads": {
|
||||||
|
"description": "Button label to unhide hidden downloads in recent access"
|
||||||
|
},
|
||||||
|
|
||||||
"recentPlaylistInfo": "Playlist: {name}",
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
"@recentPlaylistInfo": {
|
"@recentPlaylistInfo": {
|
||||||
@@ -1537,5 +1626,598 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"message": {"type": "String", "description": "Error message"}
|
"message": {"type": "String", "description": "Error message"}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
"discographyDownload": "Download Discography",
|
||||||
|
"@discographyDownload": {"description": "Button - download artist discography"},
|
||||||
|
"discographyDownloadAll": "Download All",
|
||||||
|
"@discographyDownloadAll": {"description": "Option - download entire discography"},
|
||||||
|
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||||
|
"@discographyDownloadAllSubtitle": {
|
||||||
|
"description": "Subtitle showing total tracks and albums",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"},
|
||||||
|
"albumCount": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnly": "Albums Only",
|
||||||
|
"@discographyAlbumsOnly": {"description": "Option - download only albums"},
|
||||||
|
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||||
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing album tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"},
|
||||||
|
"albumCount": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySinglesOnly": "Singles & EPs Only",
|
||||||
|
"@discographySinglesOnly": {"description": "Option - download only singles"},
|
||||||
|
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||||
|
"@discographySinglesOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing singles tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"},
|
||||||
|
"albumCount": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectAlbums": "Select Albums...",
|
||||||
|
"@discographySelectAlbums": {"description": "Option - manually select albums to download"},
|
||||||
|
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||||
|
"@discographySelectAlbumsSubtitle": {"description": "Subtitle for select albums option"},
|
||||||
|
"discographyFetchingTracks": "Fetching tracks...",
|
||||||
|
"@discographyFetchingTracks": {"description": "Progress - fetching album tracks"},
|
||||||
|
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||||
|
"@discographyFetchingAlbum": {
|
||||||
|
"description": "Progress - fetching specific album",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {"type": "int"},
|
||||||
|
"total": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectedCount": "{count} selected",
|
||||||
|
"@discographySelectedCount": {
|
||||||
|
"description": "Selection count badge",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyDownloadSelected": "Download Selected",
|
||||||
|
"@discographyDownloadSelected": {"description": "Button - download selected albums"},
|
||||||
|
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||||
|
"@discographyAddedToQueue": {
|
||||||
|
"description": "Snackbar - tracks added from discography",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||||
|
"@discographySkippedDownloaded": {
|
||||||
|
"description": "Snackbar - with skipped tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"added": {"type": "int"},
|
||||||
|
"skipped": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyNoAlbums": "No albums available",
|
||||||
|
"@discographyNoAlbums": {"description": "Error - no albums found for artist"},
|
||||||
|
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||||
|
"@discographyFailedToFetch": {"description": "Error - some albums failed to load"},
|
||||||
|
|
||||||
|
"sectionStorageAccess": "Storage Access",
|
||||||
|
"@sectionStorageAccess": {"description": "Section header for storage access settings"},
|
||||||
|
"allFilesAccess": "All Files Access",
|
||||||
|
"@allFilesAccess": {"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"},
|
||||||
|
"allFilesAccessEnabledSubtitle": "Can write to any folder",
|
||||||
|
"@allFilesAccessEnabledSubtitle": {"description": "Subtitle when all files access is enabled"},
|
||||||
|
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
|
||||||
|
"@allFilesAccessDisabledSubtitle": {"description": "Subtitle when all files access is disabled"},
|
||||||
|
"allFilesAccessDescription": "Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.",
|
||||||
|
"@allFilesAccessDescription": {"description": "Description explaining when to enable all files access"},
|
||||||
|
"allFilesAccessDeniedMessage": "Permission was denied. Please enable 'All files access' manually in system settings.",
|
||||||
|
"@allFilesAccessDeniedMessage": {"description": "Message when permission is permanently denied"},
|
||||||
|
"allFilesAccessDisabledMessage": "All Files Access disabled. The app will use limited storage access.",
|
||||||
|
"@allFilesAccessDisabledMessage": {"description": "Snackbar message when user disables all files access"},
|
||||||
|
|
||||||
|
"settingsLocalLibrary": "Local Library",
|
||||||
|
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
|
||||||
|
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
||||||
|
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
|
||||||
|
"settingsCache": "Storage & Cache",
|
||||||
|
"@settingsCache": {"description": "Settings menu item - cache management"},
|
||||||
|
"settingsCacheSubtitle": "View size and clear cached data",
|
||||||
|
"@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"},
|
||||||
|
"libraryTitle": "Local Library",
|
||||||
|
"@libraryTitle": {"description": "Library settings page title"},
|
||||||
|
"libraryStatus": "Library Status",
|
||||||
|
"@libraryStatus": {"description": "Section header for library status"},
|
||||||
|
"libraryScanSettings": "Scan Settings",
|
||||||
|
"@libraryScanSettings": {"description": "Section header for scan settings"},
|
||||||
|
"libraryEnableLocalLibrary": "Enable Local Library",
|
||||||
|
"@libraryEnableLocalLibrary": {"description": "Toggle to enable library scanning"},
|
||||||
|
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
|
||||||
|
"@libraryEnableLocalLibrarySubtitle": {"description": "Subtitle for enable toggle"},
|
||||||
|
"libraryFolder": "Library Folder",
|
||||||
|
"@libraryFolder": {"description": "Folder selection setting"},
|
||||||
|
"libraryFolderHint": "Tap to select folder",
|
||||||
|
"@libraryFolderHint": {"description": "Placeholder when no folder selected"},
|
||||||
|
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
|
||||||
|
"@libraryShowDuplicateIndicator": {"description": "Toggle for duplicate indicator in search"},
|
||||||
|
"libraryShowDuplicateIndicatorSubtitle": "Show when searching for existing tracks",
|
||||||
|
"@libraryShowDuplicateIndicatorSubtitle": {"description": "Subtitle for duplicate indicator toggle"},
|
||||||
|
"libraryActions": "Actions",
|
||||||
|
"@libraryActions": {"description": "Section header for library actions"},
|
||||||
|
"libraryScan": "Scan Library",
|
||||||
|
"@libraryScan": {"description": "Button to start library scan"},
|
||||||
|
"libraryScanSubtitle": "Scan for audio files",
|
||||||
|
"@libraryScanSubtitle": {"description": "Subtitle for scan button"},
|
||||||
|
"libraryScanSelectFolderFirst": "Select a folder first",
|
||||||
|
"@libraryScanSelectFolderFirst": {"description": "Message when trying to scan without folder"},
|
||||||
|
"libraryCleanupMissingFiles": "Cleanup Missing Files",
|
||||||
|
"@libraryCleanupMissingFiles": {"description": "Button to remove entries for missing files"},
|
||||||
|
"libraryCleanupMissingFilesSubtitle": "Remove entries for files that no longer exist",
|
||||||
|
"@libraryCleanupMissingFilesSubtitle": {"description": "Subtitle for cleanup button"},
|
||||||
|
"libraryClear": "Clear Library",
|
||||||
|
"@libraryClear": {"description": "Button to clear all library entries"},
|
||||||
|
"libraryClearSubtitle": "Remove all scanned tracks",
|
||||||
|
"@libraryClearSubtitle": {"description": "Subtitle for clear button"},
|
||||||
|
"libraryClearConfirmTitle": "Clear Library",
|
||||||
|
"@libraryClearConfirmTitle": {"description": "Dialog title for clear confirmation"},
|
||||||
|
"libraryClearConfirmMessage": "This will remove all scanned tracks from your library. Your actual music files will not be deleted.",
|
||||||
|
"@libraryClearConfirmMessage": {"description": "Dialog message for clear confirmation"},
|
||||||
|
"libraryAbout": "About Local Library",
|
||||||
|
"@libraryAbout": {"description": "Section header for about info"},
|
||||||
|
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
|
||||||
|
"@libraryAboutDescription": {"description": "Description of local library feature"},
|
||||||
|
"libraryTracksCount": "{count} tracks",
|
||||||
|
"@libraryTracksCount": {
|
||||||
|
"description": "Track count in library",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraryLastScanned": "Last scanned: {time}",
|
||||||
|
"@libraryLastScanned": {
|
||||||
|
"description": "Last scan time display",
|
||||||
|
"placeholders": {
|
||||||
|
"time": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraryLastScannedNever": "Never",
|
||||||
|
"@libraryLastScannedNever": {"description": "Shown when library has never been scanned"},
|
||||||
|
"libraryScanning": "Scanning...",
|
||||||
|
"@libraryScanning": {"description": "Status during scan"},
|
||||||
|
"libraryScanProgress": "{progress}% of {total} files",
|
||||||
|
"@libraryScanProgress": {
|
||||||
|
"description": "Scan progress display",
|
||||||
|
"placeholders": {
|
||||||
|
"progress": {"type": "String"},
|
||||||
|
"total": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraryInLibrary": "In Library",
|
||||||
|
"@libraryInLibrary": {"description": "Badge shown on tracks that exist in local library"},
|
||||||
|
"libraryRemovedMissingFiles": "Removed {count} missing files from library",
|
||||||
|
"@libraryRemovedMissingFiles": {
|
||||||
|
"description": "Snackbar after cleanup",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraryCleared": "Library cleared",
|
||||||
|
"@libraryCleared": {"description": "Snackbar after clearing library"},
|
||||||
|
"libraryStorageAccessRequired": "Storage Access Required",
|
||||||
|
"@libraryStorageAccessRequired": {"description": "Dialog title for storage permission"},
|
||||||
|
"libraryStorageAccessMessage": "SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.",
|
||||||
|
"@libraryStorageAccessMessage": {"description": "Dialog message for storage permission"},
|
||||||
|
"libraryFolderNotExist": "Selected folder does not exist",
|
||||||
|
"@libraryFolderNotExist": {"description": "Error when folder doesn't exist"},
|
||||||
|
"librarySourceDownloaded": "Downloaded",
|
||||||
|
"@librarySourceDownloaded": {"description": "Badge for tracks downloaded via SpotiFLAC"},
|
||||||
|
"librarySourceLocal": "Local",
|
||||||
|
"@librarySourceLocal": {"description": "Badge for tracks from local library scan"},
|
||||||
|
"libraryFilterAll": "All",
|
||||||
|
"@libraryFilterAll": {"description": "Filter chip - show all library items"},
|
||||||
|
"libraryFilterDownloaded": "Downloaded",
|
||||||
|
"@libraryFilterDownloaded": {"description": "Filter chip - show only downloaded items"},
|
||||||
|
"libraryFilterLocal": "Local",
|
||||||
|
"@libraryFilterLocal": {"description": "Filter chip - show only local library items"},
|
||||||
|
|
||||||
|
"libraryFilterTitle": "Filters",
|
||||||
|
"@libraryFilterTitle": {"description": "Filter bottom sheet title"},
|
||||||
|
"libraryFilterReset": "Reset",
|
||||||
|
"@libraryFilterReset": {"description": "Reset all filters button"},
|
||||||
|
"libraryFilterApply": "Apply",
|
||||||
|
"@libraryFilterApply": {"description": "Apply filters button"},
|
||||||
|
"libraryFilterSource": "Source",
|
||||||
|
"@libraryFilterSource": {"description": "Filter section - source type"},
|
||||||
|
"libraryFilterQuality": "Quality",
|
||||||
|
"@libraryFilterQuality": {"description": "Filter section - audio quality"},
|
||||||
|
"libraryFilterQualityHiRes": "Hi-Res (24bit)",
|
||||||
|
"@libraryFilterQualityHiRes": {"description": "Filter option - high resolution audio"},
|
||||||
|
"libraryFilterQualityCD": "CD (16bit)",
|
||||||
|
"@libraryFilterQualityCD": {"description": "Filter option - CD quality audio"},
|
||||||
|
"libraryFilterQualityLossy": "Lossy",
|
||||||
|
"@libraryFilterQualityLossy": {"description": "Filter option - lossy compressed audio"},
|
||||||
|
"libraryFilterFormat": "Format",
|
||||||
|
"@libraryFilterFormat": {"description": "Filter section - file format"},
|
||||||
|
"libraryFilterDate": "Date Added",
|
||||||
|
"@libraryFilterDate": {"description": "Filter section - date range"},
|
||||||
|
"libraryFilterDateToday": "Today",
|
||||||
|
"@libraryFilterDateToday": {"description": "Filter option - today only"},
|
||||||
|
"libraryFilterDateWeek": "This Week",
|
||||||
|
"@libraryFilterDateWeek": {"description": "Filter option - this week"},
|
||||||
|
"libraryFilterDateMonth": "This Month",
|
||||||
|
"@libraryFilterDateMonth": {"description": "Filter option - this month"},
|
||||||
|
"libraryFilterDateYear": "This Year",
|
||||||
|
"@libraryFilterDateYear": {"description": "Filter option - this year"},
|
||||||
|
"libraryFilterSort": "Sort",
|
||||||
|
"@libraryFilterSort": {"description": "Filter section - sort order"},
|
||||||
|
"libraryFilterSortLatest": "Latest",
|
||||||
|
"@libraryFilterSortLatest": {"description": "Sort option - newest first"},
|
||||||
|
"libraryFilterSortOldest": "Oldest",
|
||||||
|
"@libraryFilterSortOldest": {"description": "Sort option - oldest first"},
|
||||||
|
"libraryFilterActive": "{count} filter(s) active",
|
||||||
|
"@libraryFilterActive": {
|
||||||
|
"description": "Badge showing number of active filters",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"timeJustNow": "Just now",
|
||||||
|
"@timeJustNow": {"description": "Relative time - less than a minute ago"},
|
||||||
|
"timeMinutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
|
||||||
|
"@timeMinutesAgo": {
|
||||||
|
"description": "Relative time - minutes ago",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeHoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
|
||||||
|
"@timeHoursAgo": {
|
||||||
|
"description": "Relative time - hours ago",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"storageSwitchTitle": "Switch Storage Mode",
|
||||||
|
"@storageSwitchTitle": {"description": "Dialog title when switching storage mode"},
|
||||||
|
"storageSwitchToSafTitle": "Switch to SAF Storage?",
|
||||||
|
"@storageSwitchToSafTitle": {"description": "Dialog title when switching to SAF"},
|
||||||
|
"storageSwitchToAppTitle": "Switch to App Storage?",
|
||||||
|
"@storageSwitchToAppTitle": {"description": "Dialog title when switching to app storage"},
|
||||||
|
"storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.",
|
||||||
|
"@storageSwitchToSafMessage": {"description": "Explanation when switching to SAF"},
|
||||||
|
"storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.",
|
||||||
|
"@storageSwitchToAppMessage": {"description": "Explanation when switching to app storage"},
|
||||||
|
"storageSwitchExistingDownloads": "Existing Downloads",
|
||||||
|
"@storageSwitchExistingDownloads": {"description": "Section header for existing downloads info"},
|
||||||
|
"storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage",
|
||||||
|
"@storageSwitchExistingDownloadsInfo": {
|
||||||
|
"description": "Info about existing downloads count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"},
|
||||||
|
"mode": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storageSwitchNewDownloads": "New Downloads",
|
||||||
|
"@storageSwitchNewDownloads": {"description": "Section header for new downloads info"},
|
||||||
|
"storageSwitchNewDownloadsLocation": "Will be saved to: {location}",
|
||||||
|
"@storageSwitchNewDownloadsLocation": {
|
||||||
|
"description": "Shows where new downloads will go",
|
||||||
|
"placeholders": {
|
||||||
|
"location": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storageSwitchContinue": "Continue",
|
||||||
|
"@storageSwitchContinue": {"description": "Button to proceed with storage switch"},
|
||||||
|
"storageSwitchSelectFolder": "Select SAF Folder",
|
||||||
|
"@storageSwitchSelectFolder": {"description": "Button to select SAF folder"},
|
||||||
|
"storageAppStorage": "App Storage",
|
||||||
|
"@storageAppStorage": {"description": "Label for app storage mode"},
|
||||||
|
"storageSafStorage": "SAF Storage",
|
||||||
|
"@storageSafStorage": {"description": "Label for SAF storage mode"},
|
||||||
|
"storageModeBadge": "Storage: {mode}",
|
||||||
|
"@storageModeBadge": {
|
||||||
|
"description": "Badge showing storage mode for a track",
|
||||||
|
"placeholders": {
|
||||||
|
"mode": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storageStatsTitle": "Storage Statistics",
|
||||||
|
"@storageStatsTitle": {"description": "Section title for storage stats"},
|
||||||
|
"storageStatsAppCount": "{count} tracks in App Storage",
|
||||||
|
"@storageStatsAppCount": {
|
||||||
|
"description": "Count of tracks in app storage",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storageStatsSafCount": "{count} tracks in SAF Storage",
|
||||||
|
"@storageStatsSafCount": {
|
||||||
|
"description": "Count of tracks in SAF storage",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storageModeInfo": "Your files are stored in multiple locations",
|
||||||
|
"@storageModeInfo": {"description": "Info when user has files in both storage modes"},
|
||||||
|
|
||||||
|
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
|
||||||
|
"@tutorialWelcomeTitle": {"description": "Tutorial welcome page title"},
|
||||||
|
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
|
||||||
|
"@tutorialWelcomeDesc": {"description": "Tutorial welcome page description"},
|
||||||
|
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
|
||||||
|
"@tutorialWelcomeTip1": {"description": "Tutorial welcome tip 1"},
|
||||||
|
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||||
|
"@tutorialWelcomeTip2": {"description": "Tutorial welcome tip 2"},
|
||||||
|
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
|
||||||
|
"@tutorialWelcomeTip3": {"description": "Tutorial welcome tip 3"},
|
||||||
|
|
||||||
|
"tutorialSearchTitle": "Finding Music",
|
||||||
|
"@tutorialSearchTitle": {"description": "Tutorial search page title"},
|
||||||
|
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
|
||||||
|
"@tutorialSearchDesc": {"description": "Tutorial search page description"},
|
||||||
|
"tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box",
|
||||||
|
"@tutorialSearchTip1": {"description": "Tutorial search tip 1"},
|
||||||
|
"tutorialSearchTip2": "Or type the song name, artist, or album to search",
|
||||||
|
"@tutorialSearchTip2": {"description": "Tutorial search tip 2"},
|
||||||
|
"tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages",
|
||||||
|
"@tutorialSearchTip3": {"description": "Tutorial search tip 3"},
|
||||||
|
|
||||||
|
"tutorialDownloadTitle": "Downloading Music",
|
||||||
|
"@tutorialDownloadTitle": {"description": "Tutorial download page title"},
|
||||||
|
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
|
||||||
|
"@tutorialDownloadDesc": {"description": "Tutorial download page description"},
|
||||||
|
"tutorialDownloadTip1": "Tap the download button next to any track to start downloading",
|
||||||
|
"@tutorialDownloadTip1": {"description": "Tutorial download tip 1"},
|
||||||
|
"tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)",
|
||||||
|
"@tutorialDownloadTip2": {"description": "Tutorial download tip 2"},
|
||||||
|
"tutorialDownloadTip3": "Download entire albums or playlists with one tap",
|
||||||
|
"@tutorialDownloadTip3": {"description": "Tutorial download tip 3"},
|
||||||
|
|
||||||
|
"tutorialLibraryTitle": "Your Library",
|
||||||
|
"@tutorialLibraryTitle": {"description": "Tutorial library page title"},
|
||||||
|
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
|
||||||
|
"@tutorialLibraryDesc": {"description": "Tutorial library page description"},
|
||||||
|
"tutorialLibraryTip1": "View download progress and queue in the Library tab",
|
||||||
|
"@tutorialLibraryTip1": {"description": "Tutorial library tip 1"},
|
||||||
|
"tutorialLibraryTip2": "Tap any track to play it with your music player",
|
||||||
|
"@tutorialLibraryTip2": {"description": "Tutorial library tip 2"},
|
||||||
|
"tutorialLibraryTip3": "Switch between list and grid view for better browsing",
|
||||||
|
"@tutorialLibraryTip3": {"description": "Tutorial library tip 3"},
|
||||||
|
|
||||||
|
"tutorialExtensionsTitle": "Extensions",
|
||||||
|
"@tutorialExtensionsTitle": {"description": "Tutorial extensions page title"},
|
||||||
|
"tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.",
|
||||||
|
"@tutorialExtensionsDesc": {"description": "Tutorial extensions page description"},
|
||||||
|
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
||||||
|
"@tutorialExtensionsTip1": {"description": "Tutorial extensions tip 1"},
|
||||||
|
"tutorialExtensionsTip2": "Add new download providers or search sources",
|
||||||
|
"@tutorialExtensionsTip2": {"description": "Tutorial extensions tip 2"},
|
||||||
|
"tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features",
|
||||||
|
"@tutorialExtensionsTip3": {"description": "Tutorial extensions tip 3"},
|
||||||
|
|
||||||
|
"tutorialSettingsTitle": "Customize Your Experience",
|
||||||
|
"@tutorialSettingsTitle": {"description": "Tutorial settings page title"},
|
||||||
|
"tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.",
|
||||||
|
"@tutorialSettingsDesc": {"description": "Tutorial settings page description"},
|
||||||
|
"tutorialSettingsTip1": "Change download location and folder organization",
|
||||||
|
"@tutorialSettingsTip1": {"description": "Tutorial settings tip 1"},
|
||||||
|
"tutorialSettingsTip2": "Set default audio quality and format preferences",
|
||||||
|
"@tutorialSettingsTip2": {"description": "Tutorial settings tip 2"},
|
||||||
|
"tutorialSettingsTip3": "Customize app theme and appearance",
|
||||||
|
"@tutorialSettingsTip3": {"description": "Tutorial settings tip 3"},
|
||||||
|
|
||||||
|
"tutorialReadyMessage": "You're all set! Start downloading your favorite music now.",
|
||||||
|
"@tutorialReadyMessage": {"description": "Tutorial completion message"},
|
||||||
|
"tutorialExample": "EXAMPLE",
|
||||||
|
"@tutorialExample": {"description": "Example label in tutorial"},
|
||||||
|
|
||||||
|
"libraryForceFullScan": "Force Full Scan",
|
||||||
|
"@libraryForceFullScan": {"description": "Button to force a complete rescan of library"},
|
||||||
|
"libraryForceFullScanSubtitle": "Rescan all files, ignoring cache",
|
||||||
|
"@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"},
|
||||||
|
|
||||||
|
"cleanupOrphanedDownloads": "Cleanup Orphaned Downloads",
|
||||||
|
"@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"},
|
||||||
|
"cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist",
|
||||||
|
"@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"},
|
||||||
|
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
|
||||||
|
"@cleanupOrphanedDownloadsResult": {
|
||||||
|
"description": "Snackbar after orphan cleanup",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
|
||||||
|
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
|
||||||
|
|
||||||
|
"cacheTitle": "Storage & Cache",
|
||||||
|
"@cacheTitle": {"description": "Cache management page title"},
|
||||||
|
"cacheSummaryTitle": "Cache overview",
|
||||||
|
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
|
||||||
|
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
|
||||||
|
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
|
||||||
|
"cacheEstimatedTotal": "Estimated cache usage: {size}",
|
||||||
|
"@cacheEstimatedTotal": {
|
||||||
|
"description": "Total cache size shown in summary",
|
||||||
|
"placeholders": {
|
||||||
|
"size": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheSectionStorage": "Cached Data",
|
||||||
|
"@cacheSectionStorage": {"description": "Section header for cache entries"},
|
||||||
|
"cacheSectionMaintenance": "Maintenance",
|
||||||
|
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
|
||||||
|
"cacheAppDirectory": "App cache directory",
|
||||||
|
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
|
||||||
|
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
|
||||||
|
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
|
||||||
|
"cacheTempDirectory": "Temporary directory",
|
||||||
|
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
|
||||||
|
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
|
||||||
|
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
|
||||||
|
"cacheCoverImage": "Cover image cache",
|
||||||
|
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
|
||||||
|
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
|
||||||
|
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
|
||||||
|
"cacheLibraryCover": "Library cover cache",
|
||||||
|
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
|
||||||
|
"cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.",
|
||||||
|
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
|
||||||
|
"cacheExploreFeed": "Explore feed cache",
|
||||||
|
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
|
||||||
|
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
|
||||||
|
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
|
||||||
|
"cacheTrackLookup": "Track lookup cache",
|
||||||
|
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
|
||||||
|
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
|
||||||
|
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
|
||||||
|
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
|
||||||
|
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
|
||||||
|
"cacheNoData": "No cached data",
|
||||||
|
"@cacheNoData": {"description": "Label when cache category has no data"},
|
||||||
|
"cacheSizeWithFiles": "{size} in {count} files",
|
||||||
|
"@cacheSizeWithFiles": {
|
||||||
|
"description": "Cache size and file count",
|
||||||
|
"placeholders": {
|
||||||
|
"size": {"type": "String"},
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheSizeOnly": "{size}",
|
||||||
|
"@cacheSizeOnly": {
|
||||||
|
"description": "Cache size only",
|
||||||
|
"placeholders": {
|
||||||
|
"size": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheEntries": "{count} entries",
|
||||||
|
"@cacheEntries": {
|
||||||
|
"description": "Track cache entry count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheClearSuccess": "Cleared: {target}",
|
||||||
|
"@cacheClearSuccess": {
|
||||||
|
"description": "Snackbar after clearing selected cache",
|
||||||
|
"placeholders": {
|
||||||
|
"target": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheClearConfirmTitle": "Clear cache?",
|
||||||
|
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
|
||||||
|
"cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.",
|
||||||
|
"@cacheClearConfirmMessage": {
|
||||||
|
"description": "Dialog message before clearing selected cache",
|
||||||
|
"placeholders": {
|
||||||
|
"target": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheClearAllConfirmTitle": "Clear all cache?",
|
||||||
|
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
|
||||||
|
"cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.",
|
||||||
|
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
|
||||||
|
"cacheClearAll": "Clear all cache",
|
||||||
|
"@cacheClearAll": {"description": "Button label to clear all caches"},
|
||||||
|
"cacheCleanupUnused": "Cleanup unused data",
|
||||||
|
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
|
||||||
|
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
|
||||||
|
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
|
||||||
|
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
|
||||||
|
"@cacheCleanupResult": {
|
||||||
|
"description": "Snackbar after unused data cleanup",
|
||||||
|
"placeholders": {
|
||||||
|
"downloadCount": {"type": "int"},
|
||||||
|
"libraryCount": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cacheRefreshStats": "Refresh stats",
|
||||||
|
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
|
||||||
|
|
||||||
|
"trackSaveCoverArt": "Save Cover Art",
|
||||||
|
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
|
||||||
|
"trackSaveCoverArtSubtitle": "Save album art as .jpg file",
|
||||||
|
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
|
||||||
|
"trackSaveLyrics": "Save Lyrics (.lrc)",
|
||||||
|
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
|
||||||
|
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
|
||||||
|
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
|
||||||
|
"trackSaveLyricsProgress": "Saving lyrics...",
|
||||||
|
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
|
||||||
|
"trackReEnrich": "Re-enrich Metadata",
|
||||||
|
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
|
||||||
|
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
|
||||||
|
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
|
||||||
|
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
|
||||||
|
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
|
||||||
|
"trackEditMetadata": "Edit Metadata",
|
||||||
|
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
|
||||||
|
"trackCoverSaved": "Cover art saved to {fileName}",
|
||||||
|
"@trackCoverSaved": {
|
||||||
|
"description": "Snackbar after cover art saved",
|
||||||
|
"placeholders": {
|
||||||
|
"fileName": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackCoverNoSource": "No cover art source available",
|
||||||
|
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
|
||||||
|
"trackLyricsSaved": "Lyrics saved to {fileName}",
|
||||||
|
"@trackLyricsSaved": {
|
||||||
|
"description": "Snackbar after lyrics saved",
|
||||||
|
"placeholders": {
|
||||||
|
"fileName": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackReEnrichProgress": "Re-enriching metadata...",
|
||||||
|
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
|
||||||
|
"trackReEnrichSearching": "Searching metadata online...",
|
||||||
|
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
|
||||||
|
"trackReEnrichSuccess": "Metadata re-enriched successfully",
|
||||||
|
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
|
||||||
|
"trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed",
|
||||||
|
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
|
||||||
|
"trackSaveFailed": "Failed: {error}",
|
||||||
|
"@trackSaveFailed": {
|
||||||
|
"description": "Snackbar when save operation fails",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"trackConvertFormat": "Convert Format",
|
||||||
|
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
|
||||||
|
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||||
|
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
|
||||||
|
"trackConvertTitle": "Convert Audio",
|
||||||
|
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
|
||||||
|
"trackConvertTargetFormat": "Target Format",
|
||||||
|
"@trackConvertTargetFormat": {"description": "Label for format selection"},
|
||||||
|
"trackConvertBitrate": "Bitrate",
|
||||||
|
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
|
||||||
|
"trackConvertConfirmTitle": "Confirm Conversion",
|
||||||
|
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
|
||||||
|
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
|
||||||
|
"@trackConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {"type": "String"},
|
||||||
|
"targetFormat": {"type": "String"},
|
||||||
|
"bitrate": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConverting": "Converting audio...",
|
||||||
|
"@trackConvertConverting": {"description": "Snackbar while converting"},
|
||||||
|
"trackConvertSuccess": "Converted to {format} successfully",
|
||||||
|
"@trackConvertSuccess": {
|
||||||
|
"description": "Snackbar after successful conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"format": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertFailed": "Conversion failed",
|
||||||
|
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
"@historyFilterSingles": {
|
"@historyFilterSingles": {
|
||||||
"description": "Filter chip - show singles only"
|
"description": "Filter chip - show singles only"
|
||||||
},
|
},
|
||||||
"historyTracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
"historyTracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
|
||||||
"@historyTracksCount": {
|
"@historyTracksCount": {
|
||||||
"description": "Track count with plural form",
|
"description": "Track count with plural form",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbumes}}",
|
"historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbumes}}",
|
||||||
"@historyAlbumsCount": {
|
"@historyAlbumsCount": {
|
||||||
"description": "Album count with plural form",
|
"description": "Album count with plural form",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Invítame a un café",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Apoyar el desarrollo en Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "Aplicación",
|
"aboutApp": "Aplicación",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
@@ -596,7 +588,7 @@
|
|||||||
"@albumTitle": {
|
"@albumTitle": {
|
||||||
"description": "Album screen title"
|
"description": "Album screen title"
|
||||||
},
|
},
|
||||||
"albumTracks": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
"albumTracks": "{count, plural, =1{1 pista} other{{count} pistas}}",
|
||||||
"@albumTracks": {
|
"@albumTracks": {
|
||||||
"description": "Album track count",
|
"description": "Album track count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -633,7 +625,7 @@
|
|||||||
"@artistCompilations": {
|
"@artistCompilations": {
|
||||||
"description": "Section header for compilations"
|
"description": "Section header for compilations"
|
||||||
},
|
},
|
||||||
"artistReleases": "{count, plural, one {}=1{1 lanzamiento} other{{count} lanzamientos}}",
|
"artistReleases": "{count, plural, =1{1 lanzamiento} other{{count} lanzamientos}}",
|
||||||
"@artistReleases": {
|
"@artistReleases": {
|
||||||
"description": "Artist release count",
|
"description": "Artist release count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1108,7 +1100,7 @@
|
|||||||
"@dialogDeleteSelectedTitle": {
|
"@dialogDeleteSelectedTitle": {
|
||||||
"description": "Dialog title - delete selected items"
|
"description": "Dialog title - delete selected items"
|
||||||
},
|
},
|
||||||
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
"dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||||
"@dialogDeleteSelectedMessage": {
|
"@dialogDeleteSelectedMessage": {
|
||||||
"description": "Dialog message - delete selected tracks",
|
"description": "Dialog message - delete selected tracks",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1169,7 +1161,7 @@
|
|||||||
"@snackbarCredentialsCleared": {
|
"@snackbarCredentialsCleared": {
|
||||||
"description": "Snackbar - Spotify credentials removed"
|
"description": "Snackbar - Spotify credentials removed"
|
||||||
},
|
},
|
||||||
"snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}",
|
"snackbarDeletedTracks": "Eliminado {count} {count, plural, =1{pista} other{pistas}}",
|
||||||
"@snackbarDeletedTracks": {
|
"@snackbarDeletedTracks": {
|
||||||
"description": "Snackbar - tracks deleted",
|
"description": "Snackbar - tracks deleted",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1376,7 +1368,7 @@
|
|||||||
"@selectionTapToSelect": {
|
"@selectionTapToSelect": {
|
||||||
"description": "Hint - how to select items"
|
"description": "Hint - how to select items"
|
||||||
},
|
},
|
||||||
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
|
"selectionDeleteTracks": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
|
||||||
"@selectionDeleteTracks": {
|
"@selectionDeleteTracks": {
|
||||||
"description": "Delete button with count",
|
"description": "Delete button with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1916,7 +1908,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}",
|
"tracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}",
|
||||||
"@tracksCount": {
|
"@tracksCount": {
|
||||||
"description": "Track count display",
|
"description": "Track count display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2520,7 +2512,7 @@
|
|||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
"downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.",
|
||||||
"@downloadedAlbumDeleteMessage": {
|
"@downloadedAlbumDeleteMessage": {
|
||||||
"description": "Delete confirmation with count",
|
"description": "Delete confirmation with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2559,7 +2551,7 @@
|
|||||||
"@downloadedAlbumTapToSelect": {
|
"@downloadedAlbumTapToSelect": {
|
||||||
"description": "Selection hint"
|
"description": "Selection hint"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}",
|
"downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}",
|
||||||
"@downloadedAlbumDeleteCount": {
|
"@downloadedAlbumDeleteCount": {
|
||||||
"description": "Delete button text with count",
|
"description": "Delete button text with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -127,6 +127,10 @@
|
|||||||
"@historyNoSinglesSubtitle": {
|
"@historyNoSinglesSubtitle": {
|
||||||
"description": "Empty state subtitle for singles filter"
|
"description": "Empty state subtitle for singles filter"
|
||||||
},
|
},
|
||||||
|
"historySearchHint": "Search history...",
|
||||||
|
"@historySearchHint": {
|
||||||
|
"description": "Search bar placeholder in history"
|
||||||
|
},
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
@@ -512,6 +516,10 @@
|
|||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
|
"aboutTranslators": "Translators",
|
||||||
|
"@aboutTranslators": {
|
||||||
|
"description": "Section for translators"
|
||||||
|
},
|
||||||
"aboutSpecialThanks": "Special Thanks",
|
"aboutSpecialThanks": "Special Thanks",
|
||||||
"@aboutSpecialThanks": {
|
"@aboutSpecialThanks": {
|
||||||
"description": "Section for special thanks"
|
"description": "Section for special thanks"
|
||||||
@@ -544,18 +552,30 @@
|
|||||||
"@aboutFeatureRequestSubtitle": {
|
"@aboutFeatureRequestSubtitle": {
|
||||||
"description": "Subtitle for feature request"
|
"description": "Subtitle for feature request"
|
||||||
},
|
},
|
||||||
|
"aboutTelegramChannel": "Telegram Channel",
|
||||||
|
"@aboutTelegramChannel": {
|
||||||
|
"description": "Link to Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||||
|
"@aboutTelegramChannelSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChat": "Telegram Community",
|
||||||
|
"@aboutTelegramChat": {
|
||||||
|
"description": "Link to Telegram chat group"
|
||||||
|
},
|
||||||
|
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||||
|
"@aboutTelegramChatSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram chat"
|
||||||
|
},
|
||||||
|
"aboutSocial": "Social",
|
||||||
|
"@aboutSocial": {
|
||||||
|
"description": "Section for social links"
|
||||||
|
},
|
||||||
"aboutSupport": "Support",
|
"aboutSupport": "Support",
|
||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
@@ -1122,6 +1142,15 @@
|
|||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||||
|
"csvImportTracks": "{count} tracks from CSV",
|
||||||
|
"@csvImportTracks": {
|
||||||
|
"description": "Label shown in quality picker for CSV import",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@dialogImportPlaylistMessage": {
|
"@dialogImportPlaylistMessage": {
|
||||||
"description": "Dialog message - import playlist confirmation",
|
"description": "Dialog message - import playlist confirmation",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1851,6 +1880,42 @@
|
|||||||
"@sectionFileSettings": {
|
"@sectionFileSettings": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
|
"sectionLyrics": "Lyrics",
|
||||||
|
"@sectionLyrics": {
|
||||||
|
"description": "Settings section header"
|
||||||
|
},
|
||||||
|
"lyricsMode": "Lyrics Mode",
|
||||||
|
"@lyricsMode": {
|
||||||
|
"description": "Setting - how to save lyrics"
|
||||||
|
},
|
||||||
|
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||||
|
"@lyricsModeDescription": {
|
||||||
|
"description": "Lyrics mode picker description"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbed": "Embed in file",
|
||||||
|
"@lyricsModeEmbed": {
|
||||||
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||||
|
"@lyricsModeEmbedSubtitle": {
|
||||||
|
"description": "Subtitle for embed option"
|
||||||
|
},
|
||||||
|
"lyricsModeExternal": "External .lrc file",
|
||||||
|
"@lyricsModeExternal": {
|
||||||
|
"description": "Lyrics mode option - separate LRC file"
|
||||||
|
},
|
||||||
|
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||||
|
"@lyricsModeExternalSubtitle": {
|
||||||
|
"description": "Subtitle for external option"
|
||||||
|
},
|
||||||
|
"lyricsModeBoth": "Both",
|
||||||
|
"@lyricsModeBoth": {
|
||||||
|
"description": "Lyrics mode option - embed and external"
|
||||||
|
},
|
||||||
|
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||||
|
"@lyricsModeBothSubtitle": {
|
||||||
|
"description": "Subtitle for both option"
|
||||||
|
},
|
||||||
"sectionColor": "Color",
|
"sectionColor": "Color",
|
||||||
"@sectionColor": {
|
"@sectionColor": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
@@ -1997,6 +2062,18 @@
|
|||||||
"@trackReleaseDate": {
|
"@trackReleaseDate": {
|
||||||
"description": "Metadata label - release date"
|
"description": "Metadata label - release date"
|
||||||
},
|
},
|
||||||
|
"trackGenre": "Genre",
|
||||||
|
"@trackGenre": {
|
||||||
|
"description": "Metadata label - music genre"
|
||||||
|
},
|
||||||
|
"trackLabel": "Label",
|
||||||
|
"@trackLabel": {
|
||||||
|
"description": "Metadata label - record label"
|
||||||
|
},
|
||||||
|
"trackCopyright": "Copyright",
|
||||||
|
"@trackCopyright": {
|
||||||
|
"description": "Metadata label - copyright information"
|
||||||
|
},
|
||||||
"trackDownloaded": "Downloaded",
|
"trackDownloaded": "Downloaded",
|
||||||
"@trackDownloaded": {
|
"@trackDownloaded": {
|
||||||
"description": "Metadata label - download date"
|
"description": "Metadata label - download date"
|
||||||
@@ -2017,6 +2094,18 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
|
"trackEmbedLyrics": "Embed Lyrics",
|
||||||
|
"@trackEmbedLyrics": {
|
||||||
|
"description": "Action - embed lyrics into audio file"
|
||||||
|
},
|
||||||
|
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||||
|
"@trackLyricsEmbedded": {
|
||||||
|
"description": "Snackbar - lyrics saved to file"
|
||||||
|
},
|
||||||
|
"trackInstrumental": "Instrumental track",
|
||||||
|
"@trackInstrumental": {
|
||||||
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
|
},
|
||||||
"trackCopiedToClipboard": "Copied to clipboard",
|
"trackCopiedToClipboard": "Copied to clipboard",
|
||||||
"@trackCopiedToClipboard": {
|
"@trackCopiedToClipboard": {
|
||||||
"description": "Snackbar - content copied"
|
"description": "Snackbar - content copied"
|
||||||
@@ -2328,6 +2417,26 @@
|
|||||||
"@qualityHiResFlacMaxSubtitle": {
|
"@qualityHiResFlacMaxSubtitle": {
|
||||||
"description": "Technical spec for hi-res max"
|
"description": "Technical spec for hi-res max"
|
||||||
},
|
},
|
||||||
|
"qualityMp3": "MP3",
|
||||||
|
"@qualityMp3": {
|
||||||
|
"description": "Quality option - MP3 lossy format"
|
||||||
|
},
|
||||||
|
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||||
|
"@qualityMp3Subtitle": {
|
||||||
|
"description": "Technical spec for MP3"
|
||||||
|
},
|
||||||
|
"enableMp3Option": "Enable MP3 Option",
|
||||||
|
"@enableMp3Option": {
|
||||||
|
"description": "Setting - enable MP3 quality option"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||||
|
"@enableMp3OptionSubtitleOn": {
|
||||||
|
"description": "Subtitle when MP3 is enabled"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||||
|
"@enableMp3OptionSubtitleOff": {
|
||||||
|
"description": "Subtitle when MP3 is disabled"
|
||||||
|
},
|
||||||
"qualityNote": "Actual quality depends on track availability from the service",
|
"qualityNote": "Actual quality depends on track availability from the service",
|
||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
@@ -2516,6 +2625,14 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
|
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||||
|
"@albumFolderArtistAlbumSingles": {
|
||||||
|
"description": "Album folder option with singles inside artist"
|
||||||
|
},
|
||||||
|
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||||
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
|
"description": "Folder structure example"
|
||||||
|
},
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
@@ -2572,6 +2689,16 @@
|
|||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
|
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||||
|
"@downloadedAlbumDiscHeader": {
|
||||||
|
"description": "Header for disc separator in multi-disc albums",
|
||||||
|
"placeholders": {
|
||||||
|
"discNumber": {
|
||||||
|
"type": "int",
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"utilityFunctions": "Utility Functions",
|
"utilityFunctions": "Utility Functions",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
@@ -2611,5 +2738,123 @@
|
|||||||
"description": "Error message"
|
"description": "Error message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"discographyDownload": "Download Discography",
|
||||||
|
"@discographyDownload": {
|
||||||
|
"description": "Button - download artist discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAll": "Download All",
|
||||||
|
"@discographyDownloadAll": {
|
||||||
|
"description": "Option - download entire discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||||
|
"@discographyDownloadAllSubtitle": {
|
||||||
|
"description": "Subtitle showing total tracks and albums",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnly": "Albums Only",
|
||||||
|
"@discographyAlbumsOnly": {
|
||||||
|
"description": "Option - download only albums"
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||||
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing album tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySinglesOnly": "Singles & EPs Only",
|
||||||
|
"@discographySinglesOnly": {
|
||||||
|
"description": "Option - download only singles"
|
||||||
|
},
|
||||||
|
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||||
|
"@discographySinglesOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing singles tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectAlbums": "Select Albums...",
|
||||||
|
"@discographySelectAlbums": {
|
||||||
|
"description": "Option - manually select albums to download"
|
||||||
|
},
|
||||||
|
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||||
|
"@discographySelectAlbumsSubtitle": {
|
||||||
|
"description": "Subtitle for select albums option"
|
||||||
|
},
|
||||||
|
"discographyFetchingTracks": "Fetching tracks...",
|
||||||
|
"@discographyFetchingTracks": {
|
||||||
|
"description": "Progress - fetching album tracks"
|
||||||
|
},
|
||||||
|
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||||
|
"@discographyFetchingAlbum": {
|
||||||
|
"description": "Progress - fetching specific album",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectedCount": "{count} selected",
|
||||||
|
"@discographySelectedCount": {
|
||||||
|
"description": "Selection count badge",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyDownloadSelected": "Download Selected",
|
||||||
|
"@discographyDownloadSelected": {
|
||||||
|
"description": "Button - download selected albums"
|
||||||
|
},
|
||||||
|
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||||
|
"@discographyAddedToQueue": {
|
||||||
|
"description": "Snackbar - tracks added from discography",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||||
|
"@discographySkippedDownloaded": {
|
||||||
|
"description": "Snackbar - with skipped tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"added": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"skipped": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyNoAlbums": "No albums available",
|
||||||
|
"@discographyNoAlbums": {
|
||||||
|
"description": "Error - no albums found for artist"
|
||||||
|
},
|
||||||
|
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||||
|
"@discographyFailedToFetch": {
|
||||||
|
"description": "Error - some albums failed to load"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"@@locale": "hi",
|
"@@locale": "hi",
|
||||||
"@@last_modified": "2026-01-16",
|
"@@last_modified": "2026-01-16",
|
||||||
"appName": "SpotiFLAC",
|
"appName": "SpotiFlac",
|
||||||
"@appName": {
|
"@appName": {
|
||||||
"description": "App name - DO NOT TRANSLATE"
|
"description": "App name - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
"appDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
"appDescription": "स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।",
|
||||||
"@appDescription": {
|
"@appDescription": {
|
||||||
"description": "App description shown in about page"
|
"description": "App description shown in about page"
|
||||||
},
|
},
|
||||||
"navHome": "Home",
|
"navHome": "होम",
|
||||||
"@navHome": {
|
"@navHome": {
|
||||||
"description": "Bottom navigation - Home tab"
|
"description": "Bottom navigation - Home tab"
|
||||||
},
|
},
|
||||||
"navHistory": "History",
|
"navHistory": "इतिहास",
|
||||||
"@navHistory": {
|
"@navHistory": {
|
||||||
"description": "Bottom navigation - History tab"
|
"description": "Bottom navigation - History tab"
|
||||||
},
|
},
|
||||||
"navSettings": "Settings",
|
"navSettings": "विकल्प",
|
||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
@@ -127,6 +127,10 @@
|
|||||||
"@historyNoSinglesSubtitle": {
|
"@historyNoSinglesSubtitle": {
|
||||||
"description": "Empty state subtitle for singles filter"
|
"description": "Empty state subtitle for singles filter"
|
||||||
},
|
},
|
||||||
|
"historySearchHint": "Search history...",
|
||||||
|
"@historySearchHint": {
|
||||||
|
"description": "Search bar placeholder in history"
|
||||||
|
},
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
@@ -219,7 +223,7 @@
|
|||||||
"@quality128": {
|
"@quality128": {
|
||||||
"description": "Audio quality option - 128kbps MP3"
|
"description": "Audio quality option - 128kbps MP3"
|
||||||
},
|
},
|
||||||
"appearanceTitle": "Appearance",
|
"appearanceTitle": "दिखावट",
|
||||||
"@appearanceTitle": {
|
"@appearanceTitle": {
|
||||||
"description": "Appearance settings page title"
|
"description": "Appearance settings page title"
|
||||||
},
|
},
|
||||||
@@ -239,11 +243,11 @@
|
|||||||
"@appearanceThemeDark": {
|
"@appearanceThemeDark": {
|
||||||
"description": "Dark theme"
|
"description": "Dark theme"
|
||||||
},
|
},
|
||||||
"appearanceDynamicColor": "Dynamic Color",
|
"appearanceDynamicColor": "डायनेमिक रंग",
|
||||||
"@appearanceDynamicColor": {
|
"@appearanceDynamicColor": {
|
||||||
"description": "Material You dynamic colors"
|
"description": "Material You dynamic colors"
|
||||||
},
|
},
|
||||||
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
|
"appearanceDynamicColorSubtitle": "वॉलपेपर से रंग इस्तेमाल करें",
|
||||||
"@appearanceDynamicColorSubtitle": {
|
"@appearanceDynamicColorSubtitle": {
|
||||||
"description": "Subtitle for dynamic color"
|
"description": "Subtitle for dynamic color"
|
||||||
},
|
},
|
||||||
@@ -512,6 +516,10 @@
|
|||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
|
"aboutTranslators": "Translators",
|
||||||
|
"@aboutTranslators": {
|
||||||
|
"description": "Section for translators"
|
||||||
|
},
|
||||||
"aboutSpecialThanks": "Special Thanks",
|
"aboutSpecialThanks": "Special Thanks",
|
||||||
"@aboutSpecialThanks": {
|
"@aboutSpecialThanks": {
|
||||||
"description": "Section for special thanks"
|
"description": "Section for special thanks"
|
||||||
@@ -544,18 +552,30 @@
|
|||||||
"@aboutFeatureRequestSubtitle": {
|
"@aboutFeatureRequestSubtitle": {
|
||||||
"description": "Subtitle for feature request"
|
"description": "Subtitle for feature request"
|
||||||
},
|
},
|
||||||
|
"aboutTelegramChannel": "Telegram Channel",
|
||||||
|
"@aboutTelegramChannel": {
|
||||||
|
"description": "Link to Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||||
|
"@aboutTelegramChannelSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChat": "Telegram Community",
|
||||||
|
"@aboutTelegramChat": {
|
||||||
|
"description": "Link to Telegram chat group"
|
||||||
|
},
|
||||||
|
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||||
|
"@aboutTelegramChatSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram chat"
|
||||||
|
},
|
||||||
|
"aboutSocial": "Social",
|
||||||
|
"@aboutSocial": {
|
||||||
|
"description": "Section for social links"
|
||||||
|
},
|
||||||
"aboutSupport": "Support",
|
"aboutSupport": "Support",
|
||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
@@ -1122,6 +1142,15 @@
|
|||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||||
|
"csvImportTracks": "{count} tracks from CSV",
|
||||||
|
"@csvImportTracks": {
|
||||||
|
"description": "Label shown in quality picker for CSV import",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@dialogImportPlaylistMessage": {
|
"@dialogImportPlaylistMessage": {
|
||||||
"description": "Dialog message - import playlist confirmation",
|
"description": "Dialog message - import playlist confirmation",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1851,6 +1880,42 @@
|
|||||||
"@sectionFileSettings": {
|
"@sectionFileSettings": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
|
"sectionLyrics": "Lyrics",
|
||||||
|
"@sectionLyrics": {
|
||||||
|
"description": "Settings section header"
|
||||||
|
},
|
||||||
|
"lyricsMode": "Lyrics Mode",
|
||||||
|
"@lyricsMode": {
|
||||||
|
"description": "Setting - how to save lyrics"
|
||||||
|
},
|
||||||
|
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||||
|
"@lyricsModeDescription": {
|
||||||
|
"description": "Lyrics mode picker description"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbed": "Embed in file",
|
||||||
|
"@lyricsModeEmbed": {
|
||||||
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||||
|
"@lyricsModeEmbedSubtitle": {
|
||||||
|
"description": "Subtitle for embed option"
|
||||||
|
},
|
||||||
|
"lyricsModeExternal": "External .lrc file",
|
||||||
|
"@lyricsModeExternal": {
|
||||||
|
"description": "Lyrics mode option - separate LRC file"
|
||||||
|
},
|
||||||
|
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||||
|
"@lyricsModeExternalSubtitle": {
|
||||||
|
"description": "Subtitle for external option"
|
||||||
|
},
|
||||||
|
"lyricsModeBoth": "Both",
|
||||||
|
"@lyricsModeBoth": {
|
||||||
|
"description": "Lyrics mode option - embed and external"
|
||||||
|
},
|
||||||
|
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||||
|
"@lyricsModeBothSubtitle": {
|
||||||
|
"description": "Subtitle for both option"
|
||||||
|
},
|
||||||
"sectionColor": "Color",
|
"sectionColor": "Color",
|
||||||
"@sectionColor": {
|
"@sectionColor": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
@@ -1997,6 +2062,18 @@
|
|||||||
"@trackReleaseDate": {
|
"@trackReleaseDate": {
|
||||||
"description": "Metadata label - release date"
|
"description": "Metadata label - release date"
|
||||||
},
|
},
|
||||||
|
"trackGenre": "Genre",
|
||||||
|
"@trackGenre": {
|
||||||
|
"description": "Metadata label - music genre"
|
||||||
|
},
|
||||||
|
"trackLabel": "Label",
|
||||||
|
"@trackLabel": {
|
||||||
|
"description": "Metadata label - record label"
|
||||||
|
},
|
||||||
|
"trackCopyright": "Copyright",
|
||||||
|
"@trackCopyright": {
|
||||||
|
"description": "Metadata label - copyright information"
|
||||||
|
},
|
||||||
"trackDownloaded": "Downloaded",
|
"trackDownloaded": "Downloaded",
|
||||||
"@trackDownloaded": {
|
"@trackDownloaded": {
|
||||||
"description": "Metadata label - download date"
|
"description": "Metadata label - download date"
|
||||||
@@ -2017,6 +2094,18 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
|
"trackEmbedLyrics": "Embed Lyrics",
|
||||||
|
"@trackEmbedLyrics": {
|
||||||
|
"description": "Action - embed lyrics into audio file"
|
||||||
|
},
|
||||||
|
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||||
|
"@trackLyricsEmbedded": {
|
||||||
|
"description": "Snackbar - lyrics saved to file"
|
||||||
|
},
|
||||||
|
"trackInstrumental": "Instrumental track",
|
||||||
|
"@trackInstrumental": {
|
||||||
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
|
},
|
||||||
"trackCopiedToClipboard": "Copied to clipboard",
|
"trackCopiedToClipboard": "Copied to clipboard",
|
||||||
"@trackCopiedToClipboard": {
|
"@trackCopiedToClipboard": {
|
||||||
"description": "Snackbar - content copied"
|
"description": "Snackbar - content copied"
|
||||||
@@ -2328,6 +2417,26 @@
|
|||||||
"@qualityHiResFlacMaxSubtitle": {
|
"@qualityHiResFlacMaxSubtitle": {
|
||||||
"description": "Technical spec for hi-res max"
|
"description": "Technical spec for hi-res max"
|
||||||
},
|
},
|
||||||
|
"qualityMp3": "MP3",
|
||||||
|
"@qualityMp3": {
|
||||||
|
"description": "Quality option - MP3 lossy format"
|
||||||
|
},
|
||||||
|
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||||
|
"@qualityMp3Subtitle": {
|
||||||
|
"description": "Technical spec for MP3"
|
||||||
|
},
|
||||||
|
"enableMp3Option": "Enable MP3 Option",
|
||||||
|
"@enableMp3Option": {
|
||||||
|
"description": "Setting - enable MP3 quality option"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||||
|
"@enableMp3OptionSubtitleOn": {
|
||||||
|
"description": "Subtitle when MP3 is enabled"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||||
|
"@enableMp3OptionSubtitleOff": {
|
||||||
|
"description": "Subtitle when MP3 is disabled"
|
||||||
|
},
|
||||||
"qualityNote": "Actual quality depends on track availability from the service",
|
"qualityNote": "Actual quality depends on track availability from the service",
|
||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
@@ -2516,6 +2625,14 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
|
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||||
|
"@albumFolderArtistAlbumSingles": {
|
||||||
|
"description": "Album folder option with singles inside artist"
|
||||||
|
},
|
||||||
|
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||||
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
|
"description": "Folder structure example"
|
||||||
|
},
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
@@ -2572,6 +2689,16 @@
|
|||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
|
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||||
|
"@downloadedAlbumDiscHeader": {
|
||||||
|
"description": "Header for disc separator in multi-disc albums",
|
||||||
|
"placeholders": {
|
||||||
|
"discNumber": {
|
||||||
|
"type": "int",
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"utilityFunctions": "Utility Functions",
|
"utilityFunctions": "Utility Functions",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
@@ -2611,5 +2738,123 @@
|
|||||||
"description": "Error message"
|
"description": "Error message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"discographyDownload": "Download Discography",
|
||||||
|
"@discographyDownload": {
|
||||||
|
"description": "Button - download artist discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAll": "Download All",
|
||||||
|
"@discographyDownloadAll": {
|
||||||
|
"description": "Option - download entire discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||||
|
"@discographyDownloadAllSubtitle": {
|
||||||
|
"description": "Subtitle showing total tracks and albums",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnly": "Albums Only",
|
||||||
|
"@discographyAlbumsOnly": {
|
||||||
|
"description": "Option - download only albums"
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||||
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing album tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySinglesOnly": "Singles & EPs Only",
|
||||||
|
"@discographySinglesOnly": {
|
||||||
|
"description": "Option - download only singles"
|
||||||
|
},
|
||||||
|
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||||
|
"@discographySinglesOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing singles tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectAlbums": "Select Albums...",
|
||||||
|
"@discographySelectAlbums": {
|
||||||
|
"description": "Option - manually select albums to download"
|
||||||
|
},
|
||||||
|
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||||
|
"@discographySelectAlbumsSubtitle": {
|
||||||
|
"description": "Subtitle for select albums option"
|
||||||
|
},
|
||||||
|
"discographyFetchingTracks": "Fetching tracks...",
|
||||||
|
"@discographyFetchingTracks": {
|
||||||
|
"description": "Progress - fetching album tracks"
|
||||||
|
},
|
||||||
|
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||||
|
"@discographyFetchingAlbum": {
|
||||||
|
"description": "Progress - fetching specific album",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectedCount": "{count} selected",
|
||||||
|
"@discographySelectedCount": {
|
||||||
|
"description": "Selection count badge",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyDownloadSelected": "Download Selected",
|
||||||
|
"@discographyDownloadSelected": {
|
||||||
|
"description": "Button - download selected albums"
|
||||||
|
},
|
||||||
|
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||||
|
"@discographyAddedToQueue": {
|
||||||
|
"description": "Snackbar - tracks added from discography",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||||
|
"@discographySkippedDownloaded": {
|
||||||
|
"description": "Snackbar - with skipped tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"added": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"skipped": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyNoAlbums": "No albums available",
|
||||||
|
"@discographyNoAlbums": {
|
||||||
|
"description": "Error - no albums found for artist"
|
||||||
|
},
|
||||||
|
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||||
|
"@discographyFailedToFetch": {
|
||||||
|
"description": "Error - some albums failed to load"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,10 @@
|
|||||||
"@historyNoSinglesSubtitle": {
|
"@historyNoSinglesSubtitle": {
|
||||||
"description": "Empty state subtitle for singles filter"
|
"description": "Empty state subtitle for singles filter"
|
||||||
},
|
},
|
||||||
|
"historySearchHint": "Search history...",
|
||||||
|
"@historySearchHint": {
|
||||||
|
"description": "Search bar placeholder in history"
|
||||||
|
},
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
@@ -512,6 +516,10 @@
|
|||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
|
"aboutTranslators": "Translators",
|
||||||
|
"@aboutTranslators": {
|
||||||
|
"description": "Section for translators"
|
||||||
|
},
|
||||||
"aboutSpecialThanks": "Special Thanks",
|
"aboutSpecialThanks": "Special Thanks",
|
||||||
"@aboutSpecialThanks": {
|
"@aboutSpecialThanks": {
|
||||||
"description": "Section for special thanks"
|
"description": "Section for special thanks"
|
||||||
@@ -544,18 +552,30 @@
|
|||||||
"@aboutFeatureRequestSubtitle": {
|
"@aboutFeatureRequestSubtitle": {
|
||||||
"description": "Subtitle for feature request"
|
"description": "Subtitle for feature request"
|
||||||
},
|
},
|
||||||
|
"aboutTelegramChannel": "Telegram Channel",
|
||||||
|
"@aboutTelegramChannel": {
|
||||||
|
"description": "Link to Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||||
|
"@aboutTelegramChannelSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChat": "Telegram Community",
|
||||||
|
"@aboutTelegramChat": {
|
||||||
|
"description": "Link to Telegram chat group"
|
||||||
|
},
|
||||||
|
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||||
|
"@aboutTelegramChatSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram chat"
|
||||||
|
},
|
||||||
|
"aboutSocial": "Social",
|
||||||
|
"@aboutSocial": {
|
||||||
|
"description": "Section for social links"
|
||||||
|
},
|
||||||
"aboutSupport": "Support",
|
"aboutSupport": "Support",
|
||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
@@ -1122,6 +1142,15 @@
|
|||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||||
|
"csvImportTracks": "{count} tracks from CSV",
|
||||||
|
"@csvImportTracks": {
|
||||||
|
"description": "Label shown in quality picker for CSV import",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@dialogImportPlaylistMessage": {
|
"@dialogImportPlaylistMessage": {
|
||||||
"description": "Dialog message - import playlist confirmation",
|
"description": "Dialog message - import playlist confirmation",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1851,6 +1880,42 @@
|
|||||||
"@sectionFileSettings": {
|
"@sectionFileSettings": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
|
"sectionLyrics": "Lyrics",
|
||||||
|
"@sectionLyrics": {
|
||||||
|
"description": "Settings section header"
|
||||||
|
},
|
||||||
|
"lyricsMode": "Lyrics Mode",
|
||||||
|
"@lyricsMode": {
|
||||||
|
"description": "Setting - how to save lyrics"
|
||||||
|
},
|
||||||
|
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||||
|
"@lyricsModeDescription": {
|
||||||
|
"description": "Lyrics mode picker description"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbed": "Embed in file",
|
||||||
|
"@lyricsModeEmbed": {
|
||||||
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||||
|
"@lyricsModeEmbedSubtitle": {
|
||||||
|
"description": "Subtitle for embed option"
|
||||||
|
},
|
||||||
|
"lyricsModeExternal": "External .lrc file",
|
||||||
|
"@lyricsModeExternal": {
|
||||||
|
"description": "Lyrics mode option - separate LRC file"
|
||||||
|
},
|
||||||
|
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||||
|
"@lyricsModeExternalSubtitle": {
|
||||||
|
"description": "Subtitle for external option"
|
||||||
|
},
|
||||||
|
"lyricsModeBoth": "Both",
|
||||||
|
"@lyricsModeBoth": {
|
||||||
|
"description": "Lyrics mode option - embed and external"
|
||||||
|
},
|
||||||
|
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||||
|
"@lyricsModeBothSubtitle": {
|
||||||
|
"description": "Subtitle for both option"
|
||||||
|
},
|
||||||
"sectionColor": "Color",
|
"sectionColor": "Color",
|
||||||
"@sectionColor": {
|
"@sectionColor": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
@@ -1997,6 +2062,18 @@
|
|||||||
"@trackReleaseDate": {
|
"@trackReleaseDate": {
|
||||||
"description": "Metadata label - release date"
|
"description": "Metadata label - release date"
|
||||||
},
|
},
|
||||||
|
"trackGenre": "Genre",
|
||||||
|
"@trackGenre": {
|
||||||
|
"description": "Metadata label - music genre"
|
||||||
|
},
|
||||||
|
"trackLabel": "Label",
|
||||||
|
"@trackLabel": {
|
||||||
|
"description": "Metadata label - record label"
|
||||||
|
},
|
||||||
|
"trackCopyright": "Copyright",
|
||||||
|
"@trackCopyright": {
|
||||||
|
"description": "Metadata label - copyright information"
|
||||||
|
},
|
||||||
"trackDownloaded": "Downloaded",
|
"trackDownloaded": "Downloaded",
|
||||||
"@trackDownloaded": {
|
"@trackDownloaded": {
|
||||||
"description": "Metadata label - download date"
|
"description": "Metadata label - download date"
|
||||||
@@ -2017,6 +2094,18 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
|
"trackEmbedLyrics": "Embed Lyrics",
|
||||||
|
"@trackEmbedLyrics": {
|
||||||
|
"description": "Action - embed lyrics into audio file"
|
||||||
|
},
|
||||||
|
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||||
|
"@trackLyricsEmbedded": {
|
||||||
|
"description": "Snackbar - lyrics saved to file"
|
||||||
|
},
|
||||||
|
"trackInstrumental": "Instrumental track",
|
||||||
|
"@trackInstrumental": {
|
||||||
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
|
},
|
||||||
"trackCopiedToClipboard": "Copied to clipboard",
|
"trackCopiedToClipboard": "Copied to clipboard",
|
||||||
"@trackCopiedToClipboard": {
|
"@trackCopiedToClipboard": {
|
||||||
"description": "Snackbar - content copied"
|
"description": "Snackbar - content copied"
|
||||||
@@ -2328,6 +2417,26 @@
|
|||||||
"@qualityHiResFlacMaxSubtitle": {
|
"@qualityHiResFlacMaxSubtitle": {
|
||||||
"description": "Technical spec for hi-res max"
|
"description": "Technical spec for hi-res max"
|
||||||
},
|
},
|
||||||
|
"qualityMp3": "MP3",
|
||||||
|
"@qualityMp3": {
|
||||||
|
"description": "Quality option - MP3 lossy format"
|
||||||
|
},
|
||||||
|
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||||
|
"@qualityMp3Subtitle": {
|
||||||
|
"description": "Technical spec for MP3"
|
||||||
|
},
|
||||||
|
"enableMp3Option": "Enable MP3 Option",
|
||||||
|
"@enableMp3Option": {
|
||||||
|
"description": "Setting - enable MP3 quality option"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||||
|
"@enableMp3OptionSubtitleOn": {
|
||||||
|
"description": "Subtitle when MP3 is enabled"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||||
|
"@enableMp3OptionSubtitleOff": {
|
||||||
|
"description": "Subtitle when MP3 is disabled"
|
||||||
|
},
|
||||||
"qualityNote": "Actual quality depends on track availability from the service",
|
"qualityNote": "Actual quality depends on track availability from the service",
|
||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
@@ -2516,6 +2625,14 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
|
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||||
|
"@albumFolderArtistAlbumSingles": {
|
||||||
|
"description": "Album folder option with singles inside artist"
|
||||||
|
},
|
||||||
|
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||||
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
|
"description": "Folder structure example"
|
||||||
|
},
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
@@ -2572,6 +2689,16 @@
|
|||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
|
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||||
|
"@downloadedAlbumDiscHeader": {
|
||||||
|
"description": "Header for disc separator in multi-disc albums",
|
||||||
|
"placeholders": {
|
||||||
|
"discNumber": {
|
||||||
|
"type": "int",
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"utilityFunctions": "Utility Functions",
|
"utilityFunctions": "Utility Functions",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
@@ -2611,5 +2738,123 @@
|
|||||||
"description": "Error message"
|
"description": "Error message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"discographyDownload": "Download Discography",
|
||||||
|
"@discographyDownload": {
|
||||||
|
"description": "Button - download artist discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAll": "Download All",
|
||||||
|
"@discographyDownloadAll": {
|
||||||
|
"description": "Option - download entire discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||||
|
"@discographyDownloadAllSubtitle": {
|
||||||
|
"description": "Subtitle showing total tracks and albums",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnly": "Albums Only",
|
||||||
|
"@discographyAlbumsOnly": {
|
||||||
|
"description": "Option - download only albums"
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||||
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing album tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySinglesOnly": "Singles & EPs Only",
|
||||||
|
"@discographySinglesOnly": {
|
||||||
|
"description": "Option - download only singles"
|
||||||
|
},
|
||||||
|
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||||
|
"@discographySinglesOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing singles tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectAlbums": "Select Albums...",
|
||||||
|
"@discographySelectAlbums": {
|
||||||
|
"description": "Option - manually select albums to download"
|
||||||
|
},
|
||||||
|
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||||
|
"@discographySelectAlbumsSubtitle": {
|
||||||
|
"description": "Subtitle for select albums option"
|
||||||
|
},
|
||||||
|
"discographyFetchingTracks": "Fetching tracks...",
|
||||||
|
"@discographyFetchingTracks": {
|
||||||
|
"description": "Progress - fetching album tracks"
|
||||||
|
},
|
||||||
|
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||||
|
"@discographyFetchingAlbum": {
|
||||||
|
"description": "Progress - fetching specific album",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectedCount": "{count} selected",
|
||||||
|
"@discographySelectedCount": {
|
||||||
|
"description": "Selection count badge",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyDownloadSelected": "Download Selected",
|
||||||
|
"@discographyDownloadSelected": {
|
||||||
|
"description": "Button - download selected albums"
|
||||||
|
},
|
||||||
|
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||||
|
"@discographyAddedToQueue": {
|
||||||
|
"description": "Snackbar - tracks added from discography",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||||
|
"@discographySkippedDownloaded": {
|
||||||
|
"description": "Snackbar - with skipped tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"added": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"skipped": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyNoAlbums": "No albums available",
|
||||||
|
"@discographyNoAlbums": {
|
||||||
|
"description": "Error - no albums found for artist"
|
||||||
|
},
|
||||||
|
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||||
|
"@discographyFailedToFetch": {
|
||||||
|
"description": "Error - some albums failed to load"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,10 @@
|
|||||||
"@historyNoSinglesSubtitle": {
|
"@historyNoSinglesSubtitle": {
|
||||||
"description": "Empty state subtitle for singles filter"
|
"description": "Empty state subtitle for singles filter"
|
||||||
},
|
},
|
||||||
|
"historySearchHint": "Search history...",
|
||||||
|
"@historySearchHint": {
|
||||||
|
"description": "Search bar placeholder in history"
|
||||||
|
},
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
@@ -512,6 +516,10 @@
|
|||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
|
"aboutTranslators": "Translators",
|
||||||
|
"@aboutTranslators": {
|
||||||
|
"description": "Section for translators"
|
||||||
|
},
|
||||||
"aboutSpecialThanks": "Special Thanks",
|
"aboutSpecialThanks": "Special Thanks",
|
||||||
"@aboutSpecialThanks": {
|
"@aboutSpecialThanks": {
|
||||||
"description": "Section for special thanks"
|
"description": "Section for special thanks"
|
||||||
@@ -544,18 +552,30 @@
|
|||||||
"@aboutFeatureRequestSubtitle": {
|
"@aboutFeatureRequestSubtitle": {
|
||||||
"description": "Subtitle for feature request"
|
"description": "Subtitle for feature request"
|
||||||
},
|
},
|
||||||
|
"aboutTelegramChannel": "Telegram Channel",
|
||||||
|
"@aboutTelegramChannel": {
|
||||||
|
"description": "Link to Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChannelSubtitle": "Announcements and updates",
|
||||||
|
"@aboutTelegramChannelSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChat": "Telegram Community",
|
||||||
|
"@aboutTelegramChat": {
|
||||||
|
"description": "Link to Telegram chat group"
|
||||||
|
},
|
||||||
|
"aboutTelegramChatSubtitle": "Chat with other users",
|
||||||
|
"@aboutTelegramChatSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram chat"
|
||||||
|
},
|
||||||
|
"aboutSocial": "Social",
|
||||||
|
"@aboutSocial": {
|
||||||
|
"description": "Section for social links"
|
||||||
|
},
|
||||||
"aboutSupport": "Support",
|
"aboutSupport": "Support",
|
||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
@@ -1122,6 +1142,15 @@
|
|||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
||||||
|
"csvImportTracks": "{count} tracks from CSV",
|
||||||
|
"@csvImportTracks": {
|
||||||
|
"description": "Label shown in quality picker for CSV import",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@dialogImportPlaylistMessage": {
|
"@dialogImportPlaylistMessage": {
|
||||||
"description": "Dialog message - import playlist confirmation",
|
"description": "Dialog message - import playlist confirmation",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1851,6 +1880,42 @@
|
|||||||
"@sectionFileSettings": {
|
"@sectionFileSettings": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
|
"sectionLyrics": "Lyrics",
|
||||||
|
"@sectionLyrics": {
|
||||||
|
"description": "Settings section header"
|
||||||
|
},
|
||||||
|
"lyricsMode": "Lyrics Mode",
|
||||||
|
"@lyricsMode": {
|
||||||
|
"description": "Setting - how to save lyrics"
|
||||||
|
},
|
||||||
|
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||||
|
"@lyricsModeDescription": {
|
||||||
|
"description": "Lyrics mode picker description"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbed": "Embed in file",
|
||||||
|
"@lyricsModeEmbed": {
|
||||||
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||||
|
"@lyricsModeEmbedSubtitle": {
|
||||||
|
"description": "Subtitle for embed option"
|
||||||
|
},
|
||||||
|
"lyricsModeExternal": "External .lrc file",
|
||||||
|
"@lyricsModeExternal": {
|
||||||
|
"description": "Lyrics mode option - separate LRC file"
|
||||||
|
},
|
||||||
|
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||||
|
"@lyricsModeExternalSubtitle": {
|
||||||
|
"description": "Subtitle for external option"
|
||||||
|
},
|
||||||
|
"lyricsModeBoth": "Both",
|
||||||
|
"@lyricsModeBoth": {
|
||||||
|
"description": "Lyrics mode option - embed and external"
|
||||||
|
},
|
||||||
|
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||||
|
"@lyricsModeBothSubtitle": {
|
||||||
|
"description": "Subtitle for both option"
|
||||||
|
},
|
||||||
"sectionColor": "Color",
|
"sectionColor": "Color",
|
||||||
"@sectionColor": {
|
"@sectionColor": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
@@ -1997,6 +2062,18 @@
|
|||||||
"@trackReleaseDate": {
|
"@trackReleaseDate": {
|
||||||
"description": "Metadata label - release date"
|
"description": "Metadata label - release date"
|
||||||
},
|
},
|
||||||
|
"trackGenre": "Genre",
|
||||||
|
"@trackGenre": {
|
||||||
|
"description": "Metadata label - music genre"
|
||||||
|
},
|
||||||
|
"trackLabel": "Label",
|
||||||
|
"@trackLabel": {
|
||||||
|
"description": "Metadata label - record label"
|
||||||
|
},
|
||||||
|
"trackCopyright": "Copyright",
|
||||||
|
"@trackCopyright": {
|
||||||
|
"description": "Metadata label - copyright information"
|
||||||
|
},
|
||||||
"trackDownloaded": "Downloaded",
|
"trackDownloaded": "Downloaded",
|
||||||
"@trackDownloaded": {
|
"@trackDownloaded": {
|
||||||
"description": "Metadata label - download date"
|
"description": "Metadata label - download date"
|
||||||
@@ -2017,6 +2094,18 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
|
"trackEmbedLyrics": "Embed Lyrics",
|
||||||
|
"@trackEmbedLyrics": {
|
||||||
|
"description": "Action - embed lyrics into audio file"
|
||||||
|
},
|
||||||
|
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||||
|
"@trackLyricsEmbedded": {
|
||||||
|
"description": "Snackbar - lyrics saved to file"
|
||||||
|
},
|
||||||
|
"trackInstrumental": "Instrumental track",
|
||||||
|
"@trackInstrumental": {
|
||||||
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
|
},
|
||||||
"trackCopiedToClipboard": "Copied to clipboard",
|
"trackCopiedToClipboard": "Copied to clipboard",
|
||||||
"@trackCopiedToClipboard": {
|
"@trackCopiedToClipboard": {
|
||||||
"description": "Snackbar - content copied"
|
"description": "Snackbar - content copied"
|
||||||
@@ -2328,6 +2417,26 @@
|
|||||||
"@qualityHiResFlacMaxSubtitle": {
|
"@qualityHiResFlacMaxSubtitle": {
|
||||||
"description": "Technical spec for hi-res max"
|
"description": "Technical spec for hi-res max"
|
||||||
},
|
},
|
||||||
|
"qualityMp3": "MP3",
|
||||||
|
"@qualityMp3": {
|
||||||
|
"description": "Quality option - MP3 lossy format"
|
||||||
|
},
|
||||||
|
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
|
||||||
|
"@qualityMp3Subtitle": {
|
||||||
|
"description": "Technical spec for MP3"
|
||||||
|
},
|
||||||
|
"enableMp3Option": "Enable MP3 Option",
|
||||||
|
"@enableMp3Option": {
|
||||||
|
"description": "Setting - enable MP3 quality option"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
|
||||||
|
"@enableMp3OptionSubtitleOn": {
|
||||||
|
"description": "Subtitle when MP3 is enabled"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
|
||||||
|
"@enableMp3OptionSubtitleOff": {
|
||||||
|
"description": "Subtitle when MP3 is disabled"
|
||||||
|
},
|
||||||
"qualityNote": "Actual quality depends on track availability from the service",
|
"qualityNote": "Actual quality depends on track availability from the service",
|
||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
@@ -2516,6 +2625,14 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
|
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||||
|
"@albumFolderArtistAlbumSingles": {
|
||||||
|
"description": "Album folder option with singles inside artist"
|
||||||
|
},
|
||||||
|
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||||
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
|
"description": "Folder structure example"
|
||||||
|
},
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
@@ -2572,6 +2689,16 @@
|
|||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
|
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||||
|
"@downloadedAlbumDiscHeader": {
|
||||||
|
"description": "Header for disc separator in multi-disc albums",
|
||||||
|
"placeholders": {
|
||||||
|
"discNumber": {
|
||||||
|
"type": "int",
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"utilityFunctions": "Utility Functions",
|
"utilityFunctions": "Utility Functions",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
@@ -2611,5 +2738,123 @@
|
|||||||
"description": "Error message"
|
"description": "Error message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"discographyDownload": "Download Discography",
|
||||||
|
"@discographyDownload": {
|
||||||
|
"description": "Button - download artist discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAll": "Download All",
|
||||||
|
"@discographyDownloadAll": {
|
||||||
|
"description": "Option - download entire discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAllSubtitle": "{count} tracks from {albumCount} releases",
|
||||||
|
"@discographyDownloadAllSubtitle": {
|
||||||
|
"description": "Subtitle showing total tracks and albums",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnly": "Albums Only",
|
||||||
|
"@discographyAlbumsOnly": {
|
||||||
|
"description": "Option - download only albums"
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnlySubtitle": "{count} tracks from {albumCount} albums",
|
||||||
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing album tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySinglesOnly": "Singles & EPs Only",
|
||||||
|
"@discographySinglesOnly": {
|
||||||
|
"description": "Option - download only singles"
|
||||||
|
},
|
||||||
|
"discographySinglesOnlySubtitle": "{count} tracks from {albumCount} singles",
|
||||||
|
"@discographySinglesOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing singles tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectAlbums": "Select Albums...",
|
||||||
|
"@discographySelectAlbums": {
|
||||||
|
"description": "Option - manually select albums to download"
|
||||||
|
},
|
||||||
|
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||||
|
"@discographySelectAlbumsSubtitle": {
|
||||||
|
"description": "Subtitle for select albums option"
|
||||||
|
},
|
||||||
|
"discographyFetchingTracks": "Fetching tracks...",
|
||||||
|
"@discographyFetchingTracks": {
|
||||||
|
"description": "Progress - fetching album tracks"
|
||||||
|
},
|
||||||
|
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||||
|
"@discographyFetchingAlbum": {
|
||||||
|
"description": "Progress - fetching specific album",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectedCount": "{count} selected",
|
||||||
|
"@discographySelectedCount": {
|
||||||
|
"description": "Selection count badge",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyDownloadSelected": "Download Selected",
|
||||||
|
"@discographyDownloadSelected": {
|
||||||
|
"description": "Button - download selected albums"
|
||||||
|
},
|
||||||
|
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||||
|
"@discographyAddedToQueue": {
|
||||||
|
"description": "Snackbar - tracks added from discography",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySkippedDownloaded": "{added} added, {skipped} already downloaded",
|
||||||
|
"@discographySkippedDownloaded": {
|
||||||
|
"description": "Snackbar - with skipped tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"added": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"skipped": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyNoAlbums": "No albums available",
|
||||||
|
"@discographyNoAlbums": {
|
||||||
|
"description": "Error - no albums found for artist"
|
||||||
|
},
|
||||||
|
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||||
|
"@discographyFailedToFetch": {
|
||||||
|
"description": "Error - some albums failed to load"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
"@historyFilterSingles": {
|
"@historyFilterSingles": {
|
||||||
"description": "Filter chip - show singles only"
|
"description": "Filter chip - show singles only"
|
||||||
},
|
},
|
||||||
"historyTracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
"historyTracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
|
||||||
"@historyTracksCount": {
|
"@historyTracksCount": {
|
||||||
"description": "Track count with plural form",
|
"description": "Track count with plural form",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"historyAlbumsCount": "{count, plural, one {}=1{1 álbum} other{{count} álbuns}}",
|
"historyAlbumsCount": "{count, plural, =1{1 álbum} other{{count} álbuns}}",
|
||||||
"@historyAlbumsCount": {
|
"@historyAlbumsCount": {
|
||||||
"description": "Album count with plural form",
|
"description": "Album count with plural form",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Compre-me um café",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Apoie o desenvolvimento na Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "Aplicativo",
|
"aboutApp": "Aplicativo",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
@@ -596,7 +588,7 @@
|
|||||||
"@albumTitle": {
|
"@albumTitle": {
|
||||||
"description": "Album screen title"
|
"description": "Album screen title"
|
||||||
},
|
},
|
||||||
"albumTracks": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
"albumTracks": "{count, plural, =1{1 faixa} other{{count} faixas}}",
|
||||||
"@albumTracks": {
|
"@albumTracks": {
|
||||||
"description": "Album track count",
|
"description": "Album track count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -633,7 +625,7 @@
|
|||||||
"@artistCompilations": {
|
"@artistCompilations": {
|
||||||
"description": "Section header for compilations"
|
"description": "Section header for compilations"
|
||||||
},
|
},
|
||||||
"artistReleases": "{count, plural, one {}=1{1 lançamento} other{{count} lançamentos}}",
|
"artistReleases": "{count, plural, =1{1 lançamento} other{{count} lançamentos}}",
|
||||||
"@artistReleases": {
|
"@artistReleases": {
|
||||||
"description": "Artist release count",
|
"description": "Artist release count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -835,19 +827,19 @@
|
|||||||
"@setupIosEmptyFolderWarning": {
|
"@setupIosEmptyFolderWarning": {
|
||||||
"description": "iOS folder selection warning"
|
"description": "iOS folder selection warning"
|
||||||
},
|
},
|
||||||
"setupDownloadInFlac": "Download Spotify tracks in FLAC",
|
"setupDownloadInFlac": "Baixe faixas do Spotify em FLAC",
|
||||||
"@setupDownloadInFlac": {
|
"@setupDownloadInFlac": {
|
||||||
"description": "App tagline in setup"
|
"description": "App tagline in setup"
|
||||||
},
|
},
|
||||||
"setupStepStorage": "Storage",
|
"setupStepStorage": "Armazenamento",
|
||||||
"@setupStepStorage": {
|
"@setupStepStorage": {
|
||||||
"description": "Setup step indicator - storage"
|
"description": "Setup step indicator - storage"
|
||||||
},
|
},
|
||||||
"setupStepNotification": "Notification",
|
"setupStepNotification": "Notificação",
|
||||||
"@setupStepNotification": {
|
"@setupStepNotification": {
|
||||||
"description": "Setup step indicator - notification"
|
"description": "Setup step indicator - notification"
|
||||||
},
|
},
|
||||||
"setupStepFolder": "Folder",
|
"setupStepFolder": "Pasta",
|
||||||
"@setupStepFolder": {
|
"@setupStepFolder": {
|
||||||
"description": "Setup step indicator - folder"
|
"description": "Setup step indicator - folder"
|
||||||
},
|
},
|
||||||
@@ -855,19 +847,19 @@
|
|||||||
"@setupStepSpotify": {
|
"@setupStepSpotify": {
|
||||||
"description": "Setup step indicator - Spotify API"
|
"description": "Setup step indicator - Spotify API"
|
||||||
},
|
},
|
||||||
"setupStepPermission": "Permission",
|
"setupStepPermission": "Permissão",
|
||||||
"@setupStepPermission": {
|
"@setupStepPermission": {
|
||||||
"description": "Setup step indicator - permission"
|
"description": "Setup step indicator - permission"
|
||||||
},
|
},
|
||||||
"setupStorageGranted": "Storage Permission Granted!",
|
"setupStorageGranted": "Permissão de Armazenamento Concedida!",
|
||||||
"@setupStorageGranted": {
|
"@setupStorageGranted": {
|
||||||
"description": "Success message for storage permission"
|
"description": "Success message for storage permission"
|
||||||
},
|
},
|
||||||
"setupStorageRequired": "Storage Permission Required",
|
"setupStorageRequired": "Permissão de Armazenamento Necessária",
|
||||||
"@setupStorageRequired": {
|
"@setupStorageRequired": {
|
||||||
"description": "Title when storage permission needed"
|
"description": "Title when storage permission needed"
|
||||||
},
|
},
|
||||||
"setupStorageDescription": "SpotiFLAC needs storage permission to save your downloaded music files.",
|
"setupStorageDescription": "O SpotiFLAC precisa de permissão de armazenamento para salvar os seus arquivos de música baixados.",
|
||||||
"@setupStorageDescription": {
|
"@setupStorageDescription": {
|
||||||
"description": "Explanation for storage permission"
|
"description": "Explanation for storage permission"
|
||||||
},
|
},
|
||||||
@@ -1071,23 +1063,23 @@
|
|||||||
"@dialogClearAllDownloads": {
|
"@dialogClearAllDownloads": {
|
||||||
"description": "Dialog message - clear downloads confirmation"
|
"description": "Dialog message - clear downloads confirmation"
|
||||||
},
|
},
|
||||||
"dialogRemoveFromDevice": "Remove from device?",
|
"dialogRemoveFromDevice": "Remover do dispositivo?",
|
||||||
"@dialogRemoveFromDevice": {
|
"@dialogRemoveFromDevice": {
|
||||||
"description": "Dialog title - delete file confirmation"
|
"description": "Dialog title - delete file confirmation"
|
||||||
},
|
},
|
||||||
"dialogRemoveExtension": "Remove Extension",
|
"dialogRemoveExtension": "Remover Extensão",
|
||||||
"@dialogRemoveExtension": {
|
"@dialogRemoveExtension": {
|
||||||
"description": "Dialog title - uninstall extension"
|
"description": "Dialog title - uninstall extension"
|
||||||
},
|
},
|
||||||
"dialogRemoveExtensionMessage": "Are you sure you want to remove this extension? This cannot be undone.",
|
"dialogRemoveExtensionMessage": "Tem certeza de que deseja remover esta extensão? Isso não pode ser desfeito.",
|
||||||
"@dialogRemoveExtensionMessage": {
|
"@dialogRemoveExtensionMessage": {
|
||||||
"description": "Dialog message - uninstall confirmation"
|
"description": "Dialog message - uninstall confirmation"
|
||||||
},
|
},
|
||||||
"dialogUninstallExtension": "Uninstall Extension?",
|
"dialogUninstallExtension": "Desinstalar Extensão?",
|
||||||
"@dialogUninstallExtension": {
|
"@dialogUninstallExtension": {
|
||||||
"description": "Dialog title - uninstall extension"
|
"description": "Dialog title - uninstall extension"
|
||||||
},
|
},
|
||||||
"dialogUninstallExtensionMessage": "Are you sure you want to remove {extensionName}?",
|
"dialogUninstallExtensionMessage": "Tem certeza de que deseja remover {extensionName}?",
|
||||||
"@dialogUninstallExtensionMessage": {
|
"@dialogUninstallExtensionMessage": {
|
||||||
"description": "Dialog message - uninstall specific extension",
|
"description": "Dialog message - uninstall specific extension",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1096,19 +1088,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dialogClearHistoryTitle": "Clear History",
|
"dialogClearHistoryTitle": "Limpar Histórico",
|
||||||
"@dialogClearHistoryTitle": {
|
"@dialogClearHistoryTitle": {
|
||||||
"description": "Dialog title - clear download history"
|
"description": "Dialog title - clear download history"
|
||||||
},
|
},
|
||||||
"dialogClearHistoryMessage": "Are you sure you want to clear all download history? This cannot be undone.",
|
"dialogClearHistoryMessage": "Tem certeza de que deseja limpar todo o histórico de downloads? Isso não pode ser desfeito.",
|
||||||
"@dialogClearHistoryMessage": {
|
"@dialogClearHistoryMessage": {
|
||||||
"description": "Dialog message - clear history confirmation"
|
"description": "Dialog message - clear history confirmation"
|
||||||
},
|
},
|
||||||
"dialogDeleteSelectedTitle": "Delete Selected",
|
"dialogDeleteSelectedTitle": "Apagar Selecionados",
|
||||||
"@dialogDeleteSelectedTitle": {
|
"@dialogDeleteSelectedTitle": {
|
||||||
"description": "Dialog title - delete selected items"
|
"description": "Dialog title - delete selected items"
|
||||||
},
|
},
|
||||||
"dialogDeleteSelectedMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from history?\n\nThis will also delete the files from storage.",
|
"dialogDeleteSelectedMessage": "Apagar {count} {count, plural, =1{faixa} other{faixas}} do histórico?\n\nIsso também apagará os arquivos do armazenamento.",
|
||||||
"@dialogDeleteSelectedMessage": {
|
"@dialogDeleteSelectedMessage": {
|
||||||
"description": "Dialog message - delete selected tracks",
|
"description": "Dialog message - delete selected tracks",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1117,11 +1109,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistTitle": "Import Playlist",
|
"dialogImportPlaylistTitle": "Importar Playlist",
|
||||||
"@dialogImportPlaylistTitle": {
|
"@dialogImportPlaylistTitle": {
|
||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
|
"dialogImportPlaylistMessage": "Encontradas {count} faixas no CSV. Adicionar à fila de download?",
|
||||||
"@dialogImportPlaylistMessage": {
|
"@dialogImportPlaylistMessage": {
|
||||||
"description": "Dialog message - import playlist confirmation",
|
"description": "Dialog message - import playlist confirmation",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1130,7 +1122,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarAddedToQueue": "Added \"{trackName}\" to queue",
|
"snackbarAddedToQueue": "\"{trackName}\" adicionada à fila",
|
||||||
"@snackbarAddedToQueue": {
|
"@snackbarAddedToQueue": {
|
||||||
"description": "Snackbar - track added to download queue",
|
"description": "Snackbar - track added to download queue",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1139,7 +1131,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarAddedTracksToQueue": "Added {count} tracks to queue",
|
"snackbarAddedTracksToQueue": "{count} faixas adicionadas à fila",
|
||||||
"@snackbarAddedTracksToQueue": {
|
"@snackbarAddedTracksToQueue": {
|
||||||
"description": "Snackbar - multiple tracks added to queue",
|
"description": "Snackbar - multiple tracks added to queue",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1148,7 +1140,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarAlreadyDownloaded": "\"{trackName}\" already downloaded",
|
"snackbarAlreadyDownloaded": "\"{trackName}\" já foi baixada",
|
||||||
"@snackbarAlreadyDownloaded": {
|
"@snackbarAlreadyDownloaded": {
|
||||||
"description": "Snackbar - track already exists",
|
"description": "Snackbar - track already exists",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1157,19 +1149,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarHistoryCleared": "History cleared",
|
"snackbarHistoryCleared": "Histórico limpo",
|
||||||
"@snackbarHistoryCleared": {
|
"@snackbarHistoryCleared": {
|
||||||
"description": "Snackbar - history deleted"
|
"description": "Snackbar - history deleted"
|
||||||
},
|
},
|
||||||
"snackbarCredentialsSaved": "Credentials saved",
|
"snackbarCredentialsSaved": "Credenciais salvas",
|
||||||
"@snackbarCredentialsSaved": {
|
"@snackbarCredentialsSaved": {
|
||||||
"description": "Snackbar - Spotify credentials saved"
|
"description": "Snackbar - Spotify credentials saved"
|
||||||
},
|
},
|
||||||
"snackbarCredentialsCleared": "Credentials cleared",
|
"snackbarCredentialsCleared": "Credenciais removidas",
|
||||||
"@snackbarCredentialsCleared": {
|
"@snackbarCredentialsCleared": {
|
||||||
"description": "Snackbar - Spotify credentials removed"
|
"description": "Snackbar - Spotify credentials removed"
|
||||||
},
|
},
|
||||||
"snackbarDeletedTracks": "Deleted {count} {count, plural, =1{track} other{tracks}}",
|
"snackbarDeletedTracks": "{count} {count, plural, =1{faixa apagada} other{faixas apagadas}}",
|
||||||
"@snackbarDeletedTracks": {
|
"@snackbarDeletedTracks": {
|
||||||
"description": "Snackbar - tracks deleted",
|
"description": "Snackbar - tracks deleted",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1178,7 +1170,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarCannotOpenFile": "Cannot open file: {error}",
|
"snackbarCannotOpenFile": "Não foi possível abrir o arquivo: {error}",
|
||||||
"@snackbarCannotOpenFile": {
|
"@snackbarCannotOpenFile": {
|
||||||
"description": "Snackbar - file open error",
|
"description": "Snackbar - file open error",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1187,15 +1179,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarFillAllFields": "Please fill all fields",
|
"snackbarFillAllFields": "Por favor, preencha todos os campos",
|
||||||
"@snackbarFillAllFields": {
|
"@snackbarFillAllFields": {
|
||||||
"description": "Snackbar - validation error"
|
"description": "Snackbar - validation error"
|
||||||
},
|
},
|
||||||
"snackbarViewQueue": "View Queue",
|
"snackbarViewQueue": "Ver Fila",
|
||||||
"@snackbarViewQueue": {
|
"@snackbarViewQueue": {
|
||||||
"description": "Snackbar action - view download queue"
|
"description": "Snackbar action - view download queue"
|
||||||
},
|
},
|
||||||
"snackbarFailedToLoad": "Failed to load: {error}",
|
"snackbarFailedToLoad": "Falha ao carregar: {error}",
|
||||||
"@snackbarFailedToLoad": {
|
"@snackbarFailedToLoad": {
|
||||||
"description": "Snackbar - loading error",
|
"description": "Snackbar - loading error",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1204,7 +1196,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarUrlCopied": "{platform} URL copied to clipboard",
|
"snackbarUrlCopied": "URL do {platform} copiada para a área de transferência",
|
||||||
"@snackbarUrlCopied": {
|
"@snackbarUrlCopied": {
|
||||||
"description": "Snackbar - URL copied",
|
"description": "Snackbar - URL copied",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1214,23 +1206,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarFileNotFound": "File not found",
|
"snackbarFileNotFound": "Arquivo não encontrado",
|
||||||
"@snackbarFileNotFound": {
|
"@snackbarFileNotFound": {
|
||||||
"description": "Snackbar - file doesn't exist"
|
"description": "Snackbar - file doesn't exist"
|
||||||
},
|
},
|
||||||
"snackbarSelectExtFile": "Please select a .spotiflac-ext file",
|
"snackbarSelectExtFile": "Por favor, selecione um arquivo .spotiflac-ext",
|
||||||
"@snackbarSelectExtFile": {
|
"@snackbarSelectExtFile": {
|
||||||
"description": "Snackbar - wrong file type selected"
|
"description": "Snackbar - wrong file type selected"
|
||||||
},
|
},
|
||||||
"snackbarProviderPrioritySaved": "Provider priority saved",
|
"snackbarProviderPrioritySaved": "Prioridade de provedor salva",
|
||||||
"@snackbarProviderPrioritySaved": {
|
"@snackbarProviderPrioritySaved": {
|
||||||
"description": "Snackbar - provider order saved"
|
"description": "Snackbar - provider order saved"
|
||||||
},
|
},
|
||||||
"snackbarMetadataProviderSaved": "Metadata provider priority saved",
|
"snackbarMetadataProviderSaved": "Prioridade de provedor de metadados salva",
|
||||||
"@snackbarMetadataProviderSaved": {
|
"@snackbarMetadataProviderSaved": {
|
||||||
"description": "Snackbar - metadata provider order saved"
|
"description": "Snackbar - metadata provider order saved"
|
||||||
},
|
},
|
||||||
"snackbarExtensionInstalled": "{extensionName} installed.",
|
"snackbarExtensionInstalled": "{extensionName} instalada.",
|
||||||
"@snackbarExtensionInstalled": {
|
"@snackbarExtensionInstalled": {
|
||||||
"description": "Snackbar - extension installed successfully",
|
"description": "Snackbar - extension installed successfully",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1239,7 +1231,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarExtensionUpdated": "{extensionName} updated.",
|
"snackbarExtensionUpdated": "{extensionName} atualizada.",
|
||||||
"@snackbarExtensionUpdated": {
|
"@snackbarExtensionUpdated": {
|
||||||
"description": "Snackbar - extension updated successfully",
|
"description": "Snackbar - extension updated successfully",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1248,23 +1240,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"snackbarFailedToInstall": "Failed to install extension",
|
"snackbarFailedToInstall": "Falha ao instalar extensão",
|
||||||
"@snackbarFailedToInstall": {
|
"@snackbarFailedToInstall": {
|
||||||
"description": "Snackbar - extension install error"
|
"description": "Snackbar - extension install error"
|
||||||
},
|
},
|
||||||
"snackbarFailedToUpdate": "Failed to update extension",
|
"snackbarFailedToUpdate": "Falha ao atualizar extensão",
|
||||||
"@snackbarFailedToUpdate": {
|
"@snackbarFailedToUpdate": {
|
||||||
"description": "Snackbar - extension update error"
|
"description": "Snackbar - extension update error"
|
||||||
},
|
},
|
||||||
"errorRateLimited": "Rate Limited",
|
"errorRateLimited": "Taxa Limitada",
|
||||||
"@errorRateLimited": {
|
"@errorRateLimited": {
|
||||||
"description": "Error title - too many requests"
|
"description": "Error title - too many requests"
|
||||||
},
|
},
|
||||||
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.",
|
"errorRateLimitedMessage": "Muitas solicitações. Por favor, aguarde um momento antes de pesquisar novamente.",
|
||||||
"@errorRateLimitedMessage": {
|
"@errorRateLimitedMessage": {
|
||||||
"description": "Error message - rate limit explanation"
|
"description": "Error message - rate limit explanation"
|
||||||
},
|
},
|
||||||
"errorFailedToLoad": "Failed to load {item}",
|
"errorFailedToLoad": "Falha ao carregar {item}",
|
||||||
"@errorFailedToLoad": {
|
"@errorFailedToLoad": {
|
||||||
"description": "Error message - loading failed",
|
"description": "Error message - loading failed",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1274,11 +1266,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errorNoTracksFound": "No tracks found",
|
"errorNoTracksFound": "Nenhuma faixa encontrada",
|
||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
"errorMissingExtensionSource": "Não foi possível carregar {item}: fonte de extensão ausente",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1287,23 +1279,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statusQueued": "Queued",
|
"statusQueued": "Na Fila",
|
||||||
"@statusQueued": {
|
"@statusQueued": {
|
||||||
"description": "Download status - waiting in queue"
|
"description": "Download status - waiting in queue"
|
||||||
},
|
},
|
||||||
"statusDownloading": "Downloading",
|
"statusDownloading": "Baixando",
|
||||||
"@statusDownloading": {
|
"@statusDownloading": {
|
||||||
"description": "Download status - in progress"
|
"description": "Download status - in progress"
|
||||||
},
|
},
|
||||||
"statusFinalizing": "Finalizing",
|
"statusFinalizing": "Finalizando",
|
||||||
"@statusFinalizing": {
|
"@statusFinalizing": {
|
||||||
"description": "Download status - writing metadata"
|
"description": "Download status - writing metadata"
|
||||||
},
|
},
|
||||||
"statusCompleted": "Completed",
|
"statusCompleted": "Concluído",
|
||||||
"@statusCompleted": {
|
"@statusCompleted": {
|
||||||
"description": "Download status - finished"
|
"description": "Download status - finished"
|
||||||
},
|
},
|
||||||
"statusFailed": "Failed",
|
"statusFailed": "Falhou",
|
||||||
"@statusFailed": {
|
"@statusFailed": {
|
||||||
"description": "Download status - error occurred"
|
"description": "Download status - error occurred"
|
||||||
},
|
},
|
||||||
@@ -1376,7 +1368,7 @@
|
|||||||
"@selectionTapToSelect": {
|
"@selectionTapToSelect": {
|
||||||
"description": "Hint - how to select items"
|
"description": "Hint - how to select items"
|
||||||
},
|
},
|
||||||
"selectionDeleteTracks": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}",
|
"selectionDeleteTracks": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
|
||||||
"@selectionDeleteTracks": {
|
"@selectionDeleteTracks": {
|
||||||
"description": "Delete button with count",
|
"description": "Delete button with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1735,19 +1727,19 @@
|
|||||||
"@logNetworkErrorDescription": {
|
"@logNetworkErrorDescription": {
|
||||||
"description": "Network error explanation"
|
"description": "Network error explanation"
|
||||||
},
|
},
|
||||||
"logNetworkErrorSuggestion": "Check your internet connection",
|
"logNetworkErrorSuggestion": "Verifique a sua conexão com a internet",
|
||||||
"@logNetworkErrorSuggestion": {
|
"@logNetworkErrorSuggestion": {
|
||||||
"description": "Network error fix suggestion"
|
"description": "Network error fix suggestion"
|
||||||
},
|
},
|
||||||
"logTrackNotFoundDescription": "Some tracks could not be found on download services",
|
"logTrackNotFoundDescription": "Algumas faixas não foram encontradas nos serviços de download",
|
||||||
"@logTrackNotFoundDescription": {
|
"@logTrackNotFoundDescription": {
|
||||||
"description": "Track not found explanation"
|
"description": "Track not found explanation"
|
||||||
},
|
},
|
||||||
"logTrackNotFoundSuggestion": "The track may not be available in lossless quality",
|
"logTrackNotFoundSuggestion": "A faixa pode não estar disponível em qualidade lossless",
|
||||||
"@logTrackNotFoundSuggestion": {
|
"@logTrackNotFoundSuggestion": {
|
||||||
"description": "Track not found explanation"
|
"description": "Track not found explanation"
|
||||||
},
|
},
|
||||||
"logTotalErrors": "Total errors: {count}",
|
"logTotalErrors": "Total de erros: {count}",
|
||||||
"@logTotalErrors": {
|
"@logTotalErrors": {
|
||||||
"description": "Error count display",
|
"description": "Error count display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1756,7 +1748,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"logAffected": "Affected: {domains}",
|
"logAffected": "Afetados: {domains}",
|
||||||
"@logAffected": {
|
"@logAffected": {
|
||||||
"description": "Affected domains display",
|
"description": "Affected domains display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1765,7 +1757,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"logEntriesFiltered": "Entries ({count} filtered)",
|
"logEntriesFiltered": "Entradas ({count} filtradas)",
|
||||||
"@logEntriesFiltered": {
|
"@logEntriesFiltered": {
|
||||||
"description": "Log count with filter active",
|
"description": "Log count with filter active",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1774,7 +1766,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"logEntries": "Entries ({count})",
|
"logEntries": "Entradas ({count})",
|
||||||
"@logEntries": {
|
"@logEntries": {
|
||||||
"description": "Total log count",
|
"description": "Total log count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1783,11 +1775,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"credentialsTitle": "Spotify Credentials",
|
"credentialsTitle": "Credenciais do Spotify",
|
||||||
"@credentialsTitle": {
|
"@credentialsTitle": {
|
||||||
"description": "Credentials dialog title"
|
"description": "Credentials dialog title"
|
||||||
},
|
},
|
||||||
"credentialsDescription": "Enter your Client ID and Secret to use your own Spotify application quota.",
|
"credentialsDescription": "Insira o seu Client ID e Secret para usar a sua própria cota de aplicativo do Spotify.",
|
||||||
"@credentialsDescription": {
|
"@credentialsDescription": {
|
||||||
"description": "Credentials dialog explanation"
|
"description": "Credentials dialog explanation"
|
||||||
},
|
},
|
||||||
@@ -1916,7 +1908,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}",
|
"tracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}",
|
||||||
"@tracksCount": {
|
"@tracksCount": {
|
||||||
"description": "Track count display",
|
"description": "Track count display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2001,35 +1993,35 @@
|
|||||||
"@trackDownloaded": {
|
"@trackDownloaded": {
|
||||||
"description": "Metadata label - download date"
|
"description": "Metadata label - download date"
|
||||||
},
|
},
|
||||||
"trackCopyLyrics": "Copy lyrics",
|
"trackCopyLyrics": "Copiar letras",
|
||||||
"@trackCopyLyrics": {
|
"@trackCopyLyrics": {
|
||||||
"description": "Action - copy lyrics to clipboard"
|
"description": "Action - copy lyrics to clipboard"
|
||||||
},
|
},
|
||||||
"trackLyricsNotAvailable": "Lyrics not available for this track",
|
"trackLyricsNotAvailable": "Letras não disponíveis para esta faixa",
|
||||||
"@trackLyricsNotAvailable": {
|
"@trackLyricsNotAvailable": {
|
||||||
"description": "Message when lyrics not found"
|
"description": "Message when lyrics not found"
|
||||||
},
|
},
|
||||||
"trackLyricsTimeout": "Request timed out. Try again later.",
|
"trackLyricsTimeout": "A solicitação expirou. Tente novamente mais tarde.",
|
||||||
"@trackLyricsTimeout": {
|
"@trackLyricsTimeout": {
|
||||||
"description": "Message when lyrics request times out"
|
"description": "Message when lyrics request times out"
|
||||||
},
|
},
|
||||||
"trackLyricsLoadFailed": "Failed to load lyrics",
|
"trackLyricsLoadFailed": "Falha ao carregar letras",
|
||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
"trackCopiedToClipboard": "Copied to clipboard",
|
"trackCopiedToClipboard": "Copiado para a área de transferência",
|
||||||
"@trackCopiedToClipboard": {
|
"@trackCopiedToClipboard": {
|
||||||
"description": "Snackbar - content copied"
|
"description": "Snackbar - content copied"
|
||||||
},
|
},
|
||||||
"trackDeleteConfirmTitle": "Remove from device?",
|
"trackDeleteConfirmTitle": "Remover do dispositivo?",
|
||||||
"@trackDeleteConfirmTitle": {
|
"@trackDeleteConfirmTitle": {
|
||||||
"description": "Delete confirmation title"
|
"description": "Delete confirmation title"
|
||||||
},
|
},
|
||||||
"trackDeleteConfirmMessage": "This will permanently delete the downloaded file and remove it from your history.",
|
"trackDeleteConfirmMessage": "Isso apagará permanentemente o arquivo baixado e o removerá do seu histórico.",
|
||||||
"@trackDeleteConfirmMessage": {
|
"@trackDeleteConfirmMessage": {
|
||||||
"description": "Delete confirmation message"
|
"description": "Delete confirmation message"
|
||||||
},
|
},
|
||||||
"trackCannotOpen": "Cannot open: {message}",
|
"trackCannotOpen": "Não foi possível abrir: {message}",
|
||||||
"@trackCannotOpen": {
|
"@trackCannotOpen": {
|
||||||
"description": "Error opening file",
|
"description": "Error opening file",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2038,15 +2030,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dateToday": "Today",
|
"dateToday": "Hoje",
|
||||||
"@dateToday": {
|
"@dateToday": {
|
||||||
"description": "Relative date - today"
|
"description": "Relative date - today"
|
||||||
},
|
},
|
||||||
"dateYesterday": "Yesterday",
|
"dateYesterday": "Ontem",
|
||||||
"@dateYesterday": {
|
"@dateYesterday": {
|
||||||
"description": "Relative date - yesterday"
|
"description": "Relative date - yesterday"
|
||||||
},
|
},
|
||||||
"dateDaysAgo": "{count} days ago",
|
"dateDaysAgo": "Há {count} dias",
|
||||||
"@dateDaysAgo": {
|
"@dateDaysAgo": {
|
||||||
"description": "Relative date - days ago",
|
"description": "Relative date - days ago",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2055,7 +2047,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dateWeeksAgo": "{count} weeks ago",
|
"dateWeeksAgo": "Há {count} semanas",
|
||||||
"@dateWeeksAgo": {
|
"@dateWeeksAgo": {
|
||||||
"description": "Relative date - weeks ago",
|
"description": "Relative date - weeks ago",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2064,7 +2056,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dateMonthsAgo": "{count} months ago",
|
"dateMonthsAgo": "Há {count} meses",
|
||||||
"@dateMonthsAgo": {
|
"@dateMonthsAgo": {
|
||||||
"description": "Relative date - months ago",
|
"description": "Relative date - months ago",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2073,27 +2065,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"concurrentSequential": "Sequential",
|
"concurrentSequential": "Sequencial",
|
||||||
"@concurrentSequential": {
|
"@concurrentSequential": {
|
||||||
"description": "Download mode - one at a time"
|
"description": "Download mode - one at a time"
|
||||||
},
|
},
|
||||||
"concurrentParallel2": "2 Parallel",
|
"concurrentParallel2": "2 Paralelos",
|
||||||
"@concurrentParallel2": {
|
"@concurrentParallel2": {
|
||||||
"description": "Download mode - 2 simultaneous"
|
"description": "Download mode - 2 simultaneous"
|
||||||
},
|
},
|
||||||
"concurrentParallel3": "3 Parallel",
|
"concurrentParallel3": "3 Paralelos",
|
||||||
"@concurrentParallel3": {
|
"@concurrentParallel3": {
|
||||||
"description": "Download mode - 3 simultaneous"
|
"description": "Download mode - 3 simultaneous"
|
||||||
},
|
},
|
||||||
"tapToSeeError": "Tap to see error details",
|
"tapToSeeError": "Toque para ver detalhes do erro",
|
||||||
"@tapToSeeError": {
|
"@tapToSeeError": {
|
||||||
"description": "Tooltip for failed download"
|
"description": "Tooltip for failed download"
|
||||||
},
|
},
|
||||||
"storeFilterAll": "All",
|
"storeFilterAll": "Todos",
|
||||||
"@storeFilterAll": {
|
"@storeFilterAll": {
|
||||||
"description": "Store filter - all extensions"
|
"description": "Store filter - all extensions"
|
||||||
},
|
},
|
||||||
"storeFilterMetadata": "Metadata",
|
"storeFilterMetadata": "Metadados",
|
||||||
"@storeFilterMetadata": {
|
"@storeFilterMetadata": {
|
||||||
"description": "Store filter - metadata providers"
|
"description": "Store filter - metadata providers"
|
||||||
},
|
},
|
||||||
@@ -2101,43 +2093,43 @@
|
|||||||
"@storeFilterDownload": {
|
"@storeFilterDownload": {
|
||||||
"description": "Store filter - download providers"
|
"description": "Store filter - download providers"
|
||||||
},
|
},
|
||||||
"storeFilterUtility": "Utility",
|
"storeFilterUtility": "Utilitário",
|
||||||
"@storeFilterUtility": {
|
"@storeFilterUtility": {
|
||||||
"description": "Store filter - utility extensions"
|
"description": "Store filter - utility extensions"
|
||||||
},
|
},
|
||||||
"storeFilterLyrics": "Lyrics",
|
"storeFilterLyrics": "Letras",
|
||||||
"@storeFilterLyrics": {
|
"@storeFilterLyrics": {
|
||||||
"description": "Store filter - lyrics providers"
|
"description": "Store filter - lyrics providers"
|
||||||
},
|
},
|
||||||
"storeFilterIntegration": "Integration",
|
"storeFilterIntegration": "Integração",
|
||||||
"@storeFilterIntegration": {
|
"@storeFilterIntegration": {
|
||||||
"description": "Store filter - integrations"
|
"description": "Store filter - integrations"
|
||||||
},
|
},
|
||||||
"storeClearFilters": "Clear filters",
|
"storeClearFilters": "Limpar filtros",
|
||||||
"@storeClearFilters": {
|
"@storeClearFilters": {
|
||||||
"description": "Button to clear all filters"
|
"description": "Button to clear all filters"
|
||||||
},
|
},
|
||||||
"storeNoResults": "No extensions found",
|
"storeNoResults": "Nenhuma extensão encontrada",
|
||||||
"@storeNoResults": {
|
"@storeNoResults": {
|
||||||
"description": "Empty state when no extensions match filters"
|
"description": "Empty state when no extensions match filters"
|
||||||
},
|
},
|
||||||
"extensionProviderPriority": "Provider Priority",
|
"extensionProviderPriority": "Prioridade de Provedor",
|
||||||
"@extensionProviderPriority": {
|
"@extensionProviderPriority": {
|
||||||
"description": "Extension capability - provider priority"
|
"description": "Extension capability - provider priority"
|
||||||
},
|
},
|
||||||
"extensionInstallButton": "Install Extension",
|
"extensionInstallButton": "Instalar Extensão",
|
||||||
"@extensionInstallButton": {
|
"@extensionInstallButton": {
|
||||||
"description": "Button to install extension"
|
"description": "Button to install extension"
|
||||||
},
|
},
|
||||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
"extensionDefaultProvider": "Padrão (Deezer/Spotify)",
|
||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
"extensionDefaultProviderSubtitle": "Usar pesquisa integrada",
|
||||||
"@extensionDefaultProviderSubtitle": {
|
"@extensionDefaultProviderSubtitle": {
|
||||||
"description": "Subtitle for default provider"
|
"description": "Subtitle for default provider"
|
||||||
},
|
},
|
||||||
"extensionAuthor": "Author",
|
"extensionAuthor": "Autor",
|
||||||
"@extensionAuthor": {
|
"@extensionAuthor": {
|
||||||
"description": "Extension detail - author"
|
"description": "Extension detail - author"
|
||||||
},
|
},
|
||||||
@@ -2145,43 +2137,43 @@
|
|||||||
"@extensionId": {
|
"@extensionId": {
|
||||||
"description": "Extension detail - unique ID"
|
"description": "Extension detail - unique ID"
|
||||||
},
|
},
|
||||||
"extensionError": "Error",
|
"extensionError": "Erro",
|
||||||
"@extensionError": {
|
"@extensionError": {
|
||||||
"description": "Extension detail - error message"
|
"description": "Extension detail - error message"
|
||||||
},
|
},
|
||||||
"extensionCapabilities": "Capabilities",
|
"extensionCapabilities": "Capacidades",
|
||||||
"@extensionCapabilities": {
|
"@extensionCapabilities": {
|
||||||
"description": "Section header - extension features"
|
"description": "Section header - extension features"
|
||||||
},
|
},
|
||||||
"extensionMetadataProvider": "Metadata Provider",
|
"extensionMetadataProvider": "Provedor de Metadados",
|
||||||
"@extensionMetadataProvider": {
|
"@extensionMetadataProvider": {
|
||||||
"description": "Capability - provides metadata"
|
"description": "Capability - provides metadata"
|
||||||
},
|
},
|
||||||
"extensionDownloadProvider": "Download Provider",
|
"extensionDownloadProvider": "Provedor de Download",
|
||||||
"@extensionDownloadProvider": {
|
"@extensionDownloadProvider": {
|
||||||
"description": "Capability - provides downloads"
|
"description": "Capability - provides downloads"
|
||||||
},
|
},
|
||||||
"extensionLyricsProvider": "Lyrics Provider",
|
"extensionLyricsProvider": "Provedor de Letras",
|
||||||
"@extensionLyricsProvider": {
|
"@extensionLyricsProvider": {
|
||||||
"description": "Capability - provides lyrics"
|
"description": "Capability - provides lyrics"
|
||||||
},
|
},
|
||||||
"extensionUrlHandler": "URL Handler",
|
"extensionUrlHandler": "Manipulador de URL",
|
||||||
"@extensionUrlHandler": {
|
"@extensionUrlHandler": {
|
||||||
"description": "Capability - handles URLs"
|
"description": "Capability - handles URLs"
|
||||||
},
|
},
|
||||||
"extensionQualityOptions": "Quality Options",
|
"extensionQualityOptions": "Opções de Qualidade",
|
||||||
"@extensionQualityOptions": {
|
"@extensionQualityOptions": {
|
||||||
"description": "Capability - quality selection"
|
"description": "Capability - quality selection"
|
||||||
},
|
},
|
||||||
"extensionPostProcessingHooks": "Post-Processing Hooks",
|
"extensionPostProcessingHooks": "Ganchos de Pós-Processamento",
|
||||||
"@extensionPostProcessingHooks": {
|
"@extensionPostProcessingHooks": {
|
||||||
"description": "Capability - post-processing"
|
"description": "Capability - post-processing"
|
||||||
},
|
},
|
||||||
"extensionPermissions": "Permissions",
|
"extensionPermissions": "Permissões",
|
||||||
"@extensionPermissions": {
|
"@extensionPermissions": {
|
||||||
"description": "Section header - required permissions"
|
"description": "Section header - required permissions"
|
||||||
},
|
},
|
||||||
"extensionSettings": "Settings",
|
"extensionSettings": "Configurações",
|
||||||
"@extensionSettings": {
|
"@extensionSettings": {
|
||||||
"description": "Section header - extension settings"
|
"description": "Section header - extension settings"
|
||||||
},
|
},
|
||||||
@@ -2376,31 +2368,31 @@
|
|||||||
"@folderNone": {
|
"@folderNone": {
|
||||||
"description": "Folder option - no organization"
|
"description": "Folder option - no organization"
|
||||||
},
|
},
|
||||||
"folderNoneSubtitle": "Save all files directly to download folder",
|
"folderNoneSubtitle": "Salvar todos os arquivos diretamente na pasta de download",
|
||||||
"@folderNoneSubtitle": {
|
"@folderNoneSubtitle": {
|
||||||
"description": "Subtitle for no folder organization"
|
"description": "Subtitle for no folder organization"
|
||||||
},
|
},
|
||||||
"folderArtist": "Artist",
|
"folderArtist": "Artista",
|
||||||
"@folderArtist": {
|
"@folderArtist": {
|
||||||
"description": "Folder option - by artist"
|
"description": "Folder option - by artist"
|
||||||
},
|
},
|
||||||
"folderArtistSubtitle": "Artist Name/filename",
|
"folderArtistSubtitle": "Nome do Artista/nome do arquivo",
|
||||||
"@folderArtistSubtitle": {
|
"@folderArtistSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"folderAlbum": "Album",
|
"folderAlbum": "Álbum",
|
||||||
"@folderAlbum": {
|
"@folderAlbum": {
|
||||||
"description": "Folder option - by album"
|
"description": "Folder option - by album"
|
||||||
},
|
},
|
||||||
"folderAlbumSubtitle": "Album Name/filename",
|
"folderAlbumSubtitle": "Nome do Álbum/nome do arquivo",
|
||||||
"@folderAlbumSubtitle": {
|
"@folderAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"folderArtistAlbum": "Artist/Album",
|
"folderArtistAlbum": "Artista/Álbum",
|
||||||
"@folderArtistAlbum": {
|
"@folderArtistAlbum": {
|
||||||
"description": "Folder option - nested"
|
"description": "Folder option - nested"
|
||||||
},
|
},
|
||||||
"folderArtistAlbumSubtitle": "Artist Name/Album Name/filename",
|
"folderArtistAlbumSubtitle": "Nome do Artista/Nome do Álbum/nome do arquivo",
|
||||||
"@folderArtistAlbumSubtitle": {
|
"@folderArtistAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -2424,103 +2416,103 @@
|
|||||||
"@serviceSpotify": {
|
"@serviceSpotify": {
|
||||||
"description": "Service name - DO NOT TRANSLATE"
|
"description": "Service name - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
"appearanceAmoledDark": "AMOLED Dark",
|
"appearanceAmoledDark": "AMOLED Escuro",
|
||||||
"@appearanceAmoledDark": {
|
"@appearanceAmoledDark": {
|
||||||
"description": "Theme option - pure black"
|
"description": "Theme option - pure black"
|
||||||
},
|
},
|
||||||
"appearanceAmoledDarkSubtitle": "Pure black background",
|
"appearanceAmoledDarkSubtitle": "Fundo preto puro",
|
||||||
"@appearanceAmoledDarkSubtitle": {
|
"@appearanceAmoledDarkSubtitle": {
|
||||||
"description": "Subtitle for AMOLED dark"
|
"description": "Subtitle for AMOLED dark"
|
||||||
},
|
},
|
||||||
"appearanceChooseAccentColor": "Choose Accent Color",
|
"appearanceChooseAccentColor": "Escolher Cor de Destaque",
|
||||||
"@appearanceChooseAccentColor": {
|
"@appearanceChooseAccentColor": {
|
||||||
"description": "Color picker dialog title"
|
"description": "Color picker dialog title"
|
||||||
},
|
},
|
||||||
"appearanceChooseTheme": "Theme Mode",
|
"appearanceChooseTheme": "Modo de Tema",
|
||||||
"@appearanceChooseTheme": {
|
"@appearanceChooseTheme": {
|
||||||
"description": "Theme picker dialog title"
|
"description": "Theme picker dialog title"
|
||||||
},
|
},
|
||||||
"queueTitle": "Download Queue",
|
"queueTitle": "Fila de Download",
|
||||||
"@queueTitle": {
|
"@queueTitle": {
|
||||||
"description": "Queue screen title"
|
"description": "Queue screen title"
|
||||||
},
|
},
|
||||||
"queueClearAll": "Clear All",
|
"queueClearAll": "Limpar Tudo",
|
||||||
"@queueClearAll": {
|
"@queueClearAll": {
|
||||||
"description": "Button - clear all queue items"
|
"description": "Button - clear all queue items"
|
||||||
},
|
},
|
||||||
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
|
"queueClearAllMessage": "Tem certeza de que deseja limpar todos os downloads?",
|
||||||
"@queueClearAllMessage": {
|
"@queueClearAllMessage": {
|
||||||
"description": "Clear queue confirmation"
|
"description": "Clear queue confirmation"
|
||||||
},
|
},
|
||||||
"queueEmpty": "No downloads in queue",
|
"queueEmpty": "Nenhum download na fila",
|
||||||
"@queueEmpty": {
|
"@queueEmpty": {
|
||||||
"description": "Empty queue state title"
|
"description": "Empty queue state title"
|
||||||
},
|
},
|
||||||
"queueEmptySubtitle": "Add tracks from the home screen",
|
"queueEmptySubtitle": "Adicione faixas a partir da tela inicial",
|
||||||
"@queueEmptySubtitle": {
|
"@queueEmptySubtitle": {
|
||||||
"description": "Empty queue state subtitle"
|
"description": "Empty queue state subtitle"
|
||||||
},
|
},
|
||||||
"queueClearCompleted": "Clear completed",
|
"queueClearCompleted": "Limpar concluídos",
|
||||||
"@queueClearCompleted": {
|
"@queueClearCompleted": {
|
||||||
"description": "Button - clear finished downloads"
|
"description": "Button - clear finished downloads"
|
||||||
},
|
},
|
||||||
"queueDownloadFailed": "Download Failed",
|
"queueDownloadFailed": "Download Falhou",
|
||||||
"@queueDownloadFailed": {
|
"@queueDownloadFailed": {
|
||||||
"description": "Error dialog title"
|
"description": "Error dialog title"
|
||||||
},
|
},
|
||||||
"queueTrackLabel": "Track:",
|
"queueTrackLabel": "Faixa:",
|
||||||
"@queueTrackLabel": {
|
"@queueTrackLabel": {
|
||||||
"description": "Label in error dialog"
|
"description": "Label in error dialog"
|
||||||
},
|
},
|
||||||
"queueArtistLabel": "Artist:",
|
"queueArtistLabel": "Artista:",
|
||||||
"@queueArtistLabel": {
|
"@queueArtistLabel": {
|
||||||
"description": "Label in error dialog"
|
"description": "Label in error dialog"
|
||||||
},
|
},
|
||||||
"queueErrorLabel": "Error:",
|
"queueErrorLabel": "Erro:",
|
||||||
"@queueErrorLabel": {
|
"@queueErrorLabel": {
|
||||||
"description": "Label in error dialog"
|
"description": "Label in error dialog"
|
||||||
},
|
},
|
||||||
"queueUnknownError": "Unknown error",
|
"queueUnknownError": "Erro desconhecido",
|
||||||
"@queueUnknownError": {
|
"@queueUnknownError": {
|
||||||
"description": "Fallback error message"
|
"description": "Fallback error message"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbum": "Artist / Album",
|
"albumFolderArtistAlbum": "Artista / Álbum",
|
||||||
"@albumFolderArtistAlbum": {
|
"@albumFolderArtistAlbum": {
|
||||||
"description": "Album folder option"
|
"description": "Album folder option"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/",
|
"albumFolderArtistAlbumSubtitle": "Álbuns/Nome do Artista/Nome do Álbum/",
|
||||||
"@albumFolderArtistAlbumSubtitle": {
|
"@albumFolderArtistAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistYearAlbum": "Artist / [Year] Album",
|
"albumFolderArtistYearAlbum": "Artista / [Ano] Álbum",
|
||||||
"@albumFolderArtistYearAlbum": {
|
"@albumFolderArtistYearAlbum": {
|
||||||
"description": "Album folder option with year"
|
"description": "Album folder option with year"
|
||||||
},
|
},
|
||||||
"albumFolderArtistYearAlbumSubtitle": "Albums/Artist Name/[2005] Album Name/",
|
"albumFolderArtistYearAlbumSubtitle": "Álbuns/Nome do Artista/[2005] Nome do Álbum/",
|
||||||
"@albumFolderArtistYearAlbumSubtitle": {
|
"@albumFolderArtistYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderAlbumOnly": "Album Only",
|
"albumFolderAlbumOnly": "Apenas Álbum",
|
||||||
"@albumFolderAlbumOnly": {
|
"@albumFolderAlbumOnly": {
|
||||||
"description": "Album folder option"
|
"description": "Album folder option"
|
||||||
},
|
},
|
||||||
"albumFolderAlbumOnlySubtitle": "Albums/Album Name/",
|
"albumFolderAlbumOnlySubtitle": "Álbuns/Nome do Álbum/",
|
||||||
"@albumFolderAlbumOnlySubtitle": {
|
"@albumFolderAlbumOnlySubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderYearAlbum": "[Year] Album",
|
"albumFolderYearAlbum": "[Ano] Álbum",
|
||||||
"@albumFolderYearAlbum": {
|
"@albumFolderYearAlbum": {
|
||||||
"description": "Album folder option with year"
|
"description": "Album folder option with year"
|
||||||
},
|
},
|
||||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
"albumFolderYearAlbumSubtitle": "Álbuns/[2005] Nome do Álbum/",
|
||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
"downloadedAlbumDeleteSelected": "Apagar Selecionados",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteMessage": "Delete {count} {count, plural, =1{track} other{tracks}} from this album?\n\nThis will also delete the files from storage.",
|
"downloadedAlbumDeleteMessage": "Apagar {count} {count, plural, =1{faixa} other{faixas}} deste álbum?\n\nIsso também apagará os arquivos do armazenamento.",
|
||||||
"@downloadedAlbumDeleteMessage": {
|
"@downloadedAlbumDeleteMessage": {
|
||||||
"description": "Delete confirmation with count",
|
"description": "Delete confirmation with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2529,11 +2521,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadedAlbumTracksHeader": "Tracks",
|
"downloadedAlbumTracksHeader": "Faixas",
|
||||||
"@downloadedAlbumTracksHeader": {
|
"@downloadedAlbumTracksHeader": {
|
||||||
"description": "Section header for tracks"
|
"description": "Section header for tracks"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDownloadedCount": "{count} downloaded",
|
"downloadedAlbumDownloadedCount": "{count} baixadas",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2542,7 +2534,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadedAlbumSelectedCount": "{count} selected",
|
"downloadedAlbumSelectedCount": "{count} selecionadas",
|
||||||
"@downloadedAlbumSelectedCount": {
|
"@downloadedAlbumSelectedCount": {
|
||||||
"description": "Selection count indicator",
|
"description": "Selection count indicator",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2551,15 +2543,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadedAlbumAllSelected": "All tracks selected",
|
"downloadedAlbumAllSelected": "Todas as faixas selecionadas",
|
||||||
"@downloadedAlbumAllSelected": {
|
"@downloadedAlbumAllSelected": {
|
||||||
"description": "Status - all items selected"
|
"description": "Status - all items selected"
|
||||||
},
|
},
|
||||||
"downloadedAlbumTapToSelect": "Tap tracks to select",
|
"downloadedAlbumTapToSelect": "Toque nas faixas para selecionar",
|
||||||
"@downloadedAlbumTapToSelect": {
|
"@downloadedAlbumTapToSelect": {
|
||||||
"description": "Selection hint"
|
"description": "Selection hint"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteCount": "Delete {count} {count, plural, =1{track} other{tracks}}",
|
"downloadedAlbumDeleteCount": "Apagar {count} {count, plural, =1{faixa} other{faixas}}",
|
||||||
"@downloadedAlbumDeleteCount": {
|
"@downloadedAlbumDeleteCount": {
|
||||||
"description": "Delete button text with count",
|
"description": "Delete button text with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2568,23 +2560,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
"downloadedAlbumSelectToDelete": "Selecione faixas para apagar",
|
||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
"utilityFunctions": "Utility Functions",
|
"utilityFunctions": "Funções Utilitárias",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
},
|
},
|
||||||
"recentTypeArtist": "Artist",
|
"recentTypeArtist": "Artista",
|
||||||
"@recentTypeArtist": {
|
"@recentTypeArtist": {
|
||||||
"description": "Recent access item type - artist"
|
"description": "Recent access item type - artist"
|
||||||
},
|
},
|
||||||
"recentTypeAlbum": "Album",
|
"recentTypeAlbum": "Álbum",
|
||||||
"@recentTypeAlbum": {
|
"@recentTypeAlbum": {
|
||||||
"description": "Recent access item type - album"
|
"description": "Recent access item type - album"
|
||||||
},
|
},
|
||||||
"recentTypeSong": "Song",
|
"recentTypeSong": "Música",
|
||||||
"@recentTypeSong": {
|
"@recentTypeSong": {
|
||||||
"description": "Recent access item type - song/track"
|
"description": "Recent access item type - song/track"
|
||||||
},
|
},
|
||||||
@@ -2602,7 +2594,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errorGeneric": "Error: {message}",
|
"errorGeneric": "Erro: {message}",
|
||||||
"@errorGeneric": {
|
"@errorGeneric": {
|
||||||
"description": "Generic error message format",
|
"description": "Generic error message format",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -127,6 +127,10 @@
|
|||||||
"@historyNoSinglesSubtitle": {
|
"@historyNoSinglesSubtitle": {
|
||||||
"description": "Empty state subtitle for singles filter"
|
"description": "Empty state subtitle for singles filter"
|
||||||
},
|
},
|
||||||
|
"historySearchHint": "Поиск в истории...",
|
||||||
|
"@historySearchHint": {
|
||||||
|
"description": "Search bar placeholder in history"
|
||||||
|
},
|
||||||
"settingsTitle": "Настройки",
|
"settingsTitle": "Настройки",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
@@ -512,6 +516,10 @@
|
|||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
|
"aboutTranslators": "Переводчики",
|
||||||
|
"@aboutTranslators": {
|
||||||
|
"description": "Section for translators"
|
||||||
|
},
|
||||||
"aboutSpecialThanks": "Особая благодарность",
|
"aboutSpecialThanks": "Особая благодарность",
|
||||||
"@aboutSpecialThanks": {
|
"@aboutSpecialThanks": {
|
||||||
"description": "Section for special thanks"
|
"description": "Section for special thanks"
|
||||||
@@ -544,18 +552,30 @@
|
|||||||
"@aboutFeatureRequestSubtitle": {
|
"@aboutFeatureRequestSubtitle": {
|
||||||
"description": "Subtitle for feature request"
|
"description": "Subtitle for feature request"
|
||||||
},
|
},
|
||||||
|
"aboutTelegramChannel": "Telegram канал",
|
||||||
|
"@aboutTelegramChannel": {
|
||||||
|
"description": "Link to Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChannelSubtitle": "Объявления и обновления",
|
||||||
|
"@aboutTelegramChannelSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram channel"
|
||||||
|
},
|
||||||
|
"aboutTelegramChat": "Сообщество в Telegram",
|
||||||
|
"@aboutTelegramChat": {
|
||||||
|
"description": "Link to Telegram chat group"
|
||||||
|
},
|
||||||
|
"aboutTelegramChatSubtitle": "Чат с другими пользователями",
|
||||||
|
"@aboutTelegramChatSubtitle": {
|
||||||
|
"description": "Subtitle for Telegram chat"
|
||||||
|
},
|
||||||
|
"aboutSocial": "Соцсети",
|
||||||
|
"@aboutSocial": {
|
||||||
|
"description": "Section for social links"
|
||||||
|
},
|
||||||
"aboutSupport": "Поддержка",
|
"aboutSupport": "Поддержка",
|
||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Купить мне кофе",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Поддержать разработку на Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "Приложение",
|
"aboutApp": "Приложение",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
@@ -1108,7 +1128,7 @@
|
|||||||
"@dialogDeleteSelectedTitle": {
|
"@dialogDeleteSelectedTitle": {
|
||||||
"description": "Dialog title - delete selected items"
|
"description": "Dialog title - delete selected items"
|
||||||
},
|
},
|
||||||
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
|
||||||
"@dialogDeleteSelectedMessage": {
|
"@dialogDeleteSelectedMessage": {
|
||||||
"description": "Dialog message - delete selected tracks",
|
"description": "Dialog message - delete selected tracks",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1122,6 +1142,15 @@
|
|||||||
"description": "Dialog title - import CSV playlist"
|
"description": "Dialog title - import CSV playlist"
|
||||||
},
|
},
|
||||||
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
|
"dialogImportPlaylistMessage": "Найдено {count} треков в CSV. Добавить их в очередь загрузки?",
|
||||||
|
"csvImportTracks": "{count} треков из CSV",
|
||||||
|
"@csvImportTracks": {
|
||||||
|
"description": "Label shown in quality picker for CSV import",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@dialogImportPlaylistMessage": {
|
"@dialogImportPlaylistMessage": {
|
||||||
"description": "Dialog message - import playlist confirmation",
|
"description": "Dialog message - import playlist confirmation",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1169,7 +1198,7 @@
|
|||||||
"@snackbarCredentialsCleared": {
|
"@snackbarCredentialsCleared": {
|
||||||
"description": "Snackbar - Spotify credentials removed"
|
"description": "Snackbar - Spotify credentials removed"
|
||||||
},
|
},
|
||||||
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
||||||
"@snackbarDeletedTracks": {
|
"@snackbarDeletedTracks": {
|
||||||
"description": "Snackbar - tracks deleted",
|
"description": "Snackbar - tracks deleted",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1376,7 +1405,7 @@
|
|||||||
"@selectionTapToSelect": {
|
"@selectionTapToSelect": {
|
||||||
"description": "Hint - how to select items"
|
"description": "Hint - how to select items"
|
||||||
},
|
},
|
||||||
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
||||||
"@selectionDeleteTracks": {
|
"@selectionDeleteTracks": {
|
||||||
"description": "Delete button with count",
|
"description": "Delete button with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1851,6 +1880,42 @@
|
|||||||
"@sectionFileSettings": {
|
"@sectionFileSettings": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
|
"sectionLyrics": "Тексты песен",
|
||||||
|
"@sectionLyrics": {
|
||||||
|
"description": "Settings section header"
|
||||||
|
},
|
||||||
|
"lyricsMode": "Режим текстов песен",
|
||||||
|
"@lyricsMode": {
|
||||||
|
"description": "Setting - how to save lyrics"
|
||||||
|
},
|
||||||
|
"lyricsModeDescription": "Выберите как сохранить тексты песен при скачивании",
|
||||||
|
"@lyricsModeDescription": {
|
||||||
|
"description": "Lyrics mode picker description"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbed": "Вставить в файл",
|
||||||
|
"@lyricsModeEmbed": {
|
||||||
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
|
},
|
||||||
|
"lyricsModeEmbedSubtitle": "Встроить текст в метаданные FLAC",
|
||||||
|
"@lyricsModeEmbedSubtitle": {
|
||||||
|
"description": "Subtitle for embed option"
|
||||||
|
},
|
||||||
|
"lyricsModeExternal": "Внешний файл .lrc",
|
||||||
|
"@lyricsModeExternal": {
|
||||||
|
"description": "Lyrics mode option - separate LRC file"
|
||||||
|
},
|
||||||
|
"lyricsModeExternalSubtitle": "Отдельный файл .lrc для плееров, таких, как Samsung Music",
|
||||||
|
"@lyricsModeExternalSubtitle": {
|
||||||
|
"description": "Subtitle for external option"
|
||||||
|
},
|
||||||
|
"lyricsModeBoth": "Оба варианта",
|
||||||
|
"@lyricsModeBoth": {
|
||||||
|
"description": "Lyrics mode option - embed and external"
|
||||||
|
},
|
||||||
|
"lyricsModeBothSubtitle": "Вставить и сохранить файл .lrc",
|
||||||
|
"@lyricsModeBothSubtitle": {
|
||||||
|
"description": "Subtitle for both option"
|
||||||
|
},
|
||||||
"sectionColor": "Цвет",
|
"sectionColor": "Цвет",
|
||||||
"@sectionColor": {
|
"@sectionColor": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
@@ -1997,6 +2062,18 @@
|
|||||||
"@trackReleaseDate": {
|
"@trackReleaseDate": {
|
||||||
"description": "Metadata label - release date"
|
"description": "Metadata label - release date"
|
||||||
},
|
},
|
||||||
|
"trackGenre": "Жанр",
|
||||||
|
"@trackGenre": {
|
||||||
|
"description": "Metadata label - music genre"
|
||||||
|
},
|
||||||
|
"trackLabel": "Заголовок",
|
||||||
|
"@trackLabel": {
|
||||||
|
"description": "Metadata label - record label"
|
||||||
|
},
|
||||||
|
"trackCopyright": "Авторские права",
|
||||||
|
"@trackCopyright": {
|
||||||
|
"description": "Metadata label - copyright information"
|
||||||
|
},
|
||||||
"trackDownloaded": "Скачано",
|
"trackDownloaded": "Скачано",
|
||||||
"@trackDownloaded": {
|
"@trackDownloaded": {
|
||||||
"description": "Metadata label - download date"
|
"description": "Metadata label - download date"
|
||||||
@@ -2017,6 +2094,18 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
|
"trackEmbedLyrics": "Вставить текст песни",
|
||||||
|
"@trackEmbedLyrics": {
|
||||||
|
"description": "Action - embed lyrics into audio file"
|
||||||
|
},
|
||||||
|
"trackLyricsEmbedded": "Текст успешно добавлен",
|
||||||
|
"@trackLyricsEmbedded": {
|
||||||
|
"description": "Snackbar - lyrics saved to file"
|
||||||
|
},
|
||||||
|
"trackInstrumental": "Инструментальный трек",
|
||||||
|
"@trackInstrumental": {
|
||||||
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
|
},
|
||||||
"trackCopiedToClipboard": "Скопировано в буфер обмена",
|
"trackCopiedToClipboard": "Скопировано в буфер обмена",
|
||||||
"@trackCopiedToClipboard": {
|
"@trackCopiedToClipboard": {
|
||||||
"description": "Snackbar - content copied"
|
"description": "Snackbar - content copied"
|
||||||
@@ -2328,6 +2417,26 @@
|
|||||||
"@qualityHiResFlacMaxSubtitle": {
|
"@qualityHiResFlacMaxSubtitle": {
|
||||||
"description": "Technical spec for hi-res max"
|
"description": "Technical spec for hi-res max"
|
||||||
},
|
},
|
||||||
|
"qualityMp3": "MP3",
|
||||||
|
"@qualityMp3": {
|
||||||
|
"description": "Quality option - MP3 lossy format"
|
||||||
|
},
|
||||||
|
"qualityMp3Subtitle": "320 кбит/с (Конвертировано из FLAC)",
|
||||||
|
"@qualityMp3Subtitle": {
|
||||||
|
"description": "Technical spec for MP3"
|
||||||
|
},
|
||||||
|
"enableMp3Option": "Скачивние в MP3",
|
||||||
|
"@enableMp3Option": {
|
||||||
|
"description": "Setting - enable MP3 quality option"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOn": "MP3 качество доступно",
|
||||||
|
"@enableMp3OptionSubtitleOn": {
|
||||||
|
"description": "Subtitle when MP3 is enabled"
|
||||||
|
},
|
||||||
|
"enableMp3OptionSubtitleOff": "Скачивать FLAC и конвертировать в MP3 320 кбит/с",
|
||||||
|
"@enableMp3OptionSubtitleOff": {
|
||||||
|
"description": "Subtitle when MP3 is disabled"
|
||||||
|
},
|
||||||
"qualityNote": "Фактическое качество зависит от доступности треков в сервисе",
|
"qualityNote": "Фактическое качество зависит от доступности треков в сервисе",
|
||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
@@ -2516,11 +2625,19 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
|
"albumFolderArtistAlbumSingles": "Исполнитель / Альбом + Синглы",
|
||||||
|
"@albumFolderArtistAlbumSingles": {
|
||||||
|
"description": "Album folder option with singles inside artist"
|
||||||
|
},
|
||||||
|
"albumFolderArtistAlbumSinglesSubtitle": "Исполнитель/Альбом и Исполнитель/Сингл/",
|
||||||
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
|
"description": "Folder structure example"
|
||||||
|
},
|
||||||
"downloadedAlbumDeleteSelected": "Удалить выбранные",
|
"downloadedAlbumDeleteSelected": "Удалить выбранные",
|
||||||
"@downloadedAlbumDeleteSelected": {
|
"@downloadedAlbumDeleteSelected": {
|
||||||
"description": "Button - delete selected tracks"
|
"description": "Button - delete selected tracks"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
|
||||||
"@downloadedAlbumDeleteMessage": {
|
"@downloadedAlbumDeleteMessage": {
|
||||||
"description": "Delete confirmation with count",
|
"description": "Delete confirmation with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2559,7 +2676,7 @@
|
|||||||
"@downloadedAlbumTapToSelect": {
|
"@downloadedAlbumTapToSelect": {
|
||||||
"description": "Selection hint"
|
"description": "Selection hint"
|
||||||
},
|
},
|
||||||
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
|
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other {треков}}",
|
||||||
"@downloadedAlbumDeleteCount": {
|
"@downloadedAlbumDeleteCount": {
|
||||||
"description": "Delete button text with count",
|
"description": "Delete button text with count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2572,6 +2689,16 @@
|
|||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
|
"downloadedAlbumDiscHeader": "Диск {discNumber}",
|
||||||
|
"@downloadedAlbumDiscHeader": {
|
||||||
|
"description": "Header for disc separator in multi-disc albums",
|
||||||
|
"placeholders": {
|
||||||
|
"discNumber": {
|
||||||
|
"type": "int",
|
||||||
|
"example": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"utilityFunctions": "Функции утилиты",
|
"utilityFunctions": "Функции утилиты",
|
||||||
"@utilityFunctions": {
|
"@utilityFunctions": {
|
||||||
"description": "Extension capability - utility functions"
|
"description": "Extension capability - utility functions"
|
||||||
@@ -2611,5 +2738,123 @@
|
|||||||
"description": "Error message"
|
"description": "Error message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"discographyDownload": "Скачать дискографию",
|
||||||
|
"@discographyDownload": {
|
||||||
|
"description": "Button - download artist discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAll": "Скачать всё",
|
||||||
|
"@discographyDownloadAll": {
|
||||||
|
"description": "Option - download entire discography"
|
||||||
|
},
|
||||||
|
"discographyDownloadAllSubtitle": "{count} треков из {albumCount} релизов",
|
||||||
|
"@discographyDownloadAllSubtitle": {
|
||||||
|
"description": "Subtitle showing total tracks and albums",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnly": "Только альбомы",
|
||||||
|
"@discographyAlbumsOnly": {
|
||||||
|
"description": "Option - download only albums"
|
||||||
|
},
|
||||||
|
"discographyAlbumsOnlySubtitle": "{count} треков из {albumCount} альбомов",
|
||||||
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing album tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySinglesOnly": "Только синглы и EP",
|
||||||
|
"@discographySinglesOnly": {
|
||||||
|
"description": "Option - download only singles"
|
||||||
|
},
|
||||||
|
"discographySinglesOnlySubtitle": "{count} треков из {albumCount} синглов",
|
||||||
|
"@discographySinglesOnlySubtitle": {
|
||||||
|
"description": "Subtitle showing singles tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"albumCount": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectAlbums": "Выбрать альбомы...",
|
||||||
|
"@discographySelectAlbums": {
|
||||||
|
"description": "Option - manually select albums to download"
|
||||||
|
},
|
||||||
|
"discographySelectAlbumsSubtitle": "Выберите конкретные альбомы или синглы",
|
||||||
|
"@discographySelectAlbumsSubtitle": {
|
||||||
|
"description": "Subtitle for select albums option"
|
||||||
|
},
|
||||||
|
"discographyFetchingTracks": "Получение треков...",
|
||||||
|
"@discographyFetchingTracks": {
|
||||||
|
"description": "Progress - fetching album tracks"
|
||||||
|
},
|
||||||
|
"discographyFetchingAlbum": "Получение {current} из {total}...",
|
||||||
|
"@discographyFetchingAlbum": {
|
||||||
|
"description": "Progress - fetching specific album",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySelectedCount": "{count} выбрано",
|
||||||
|
"@discographySelectedCount": {
|
||||||
|
"description": "Selection count badge",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyDownloadSelected": "Скачать выбранное",
|
||||||
|
"@discographyDownloadSelected": {
|
||||||
|
"description": "Button - download selected albums"
|
||||||
|
},
|
||||||
|
"discographyAddedToQueue": "Добавлено {count} треков в очередь",
|
||||||
|
"@discographyAddedToQueue": {
|
||||||
|
"description": "Snackbar - tracks added from discography",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographySkippedDownloaded": "{added} добавлено, {skipped} уже скачано",
|
||||||
|
"@discographySkippedDownloaded": {
|
||||||
|
"description": "Snackbar - with skipped tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"added": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"skipped": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discographyNoAlbums": "Нет доступных альбомов",
|
||||||
|
"@discographyNoAlbums": {
|
||||||
|
"description": "Error - no albums found for artist"
|
||||||
|
},
|
||||||
|
"discographyFailedToFetch": "Не удалось получить некоторые альбомы",
|
||||||
|
"@discographyFailedToFetch": {
|
||||||
|
"description": "Error - some albums failed to load"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -548,14 +548,6 @@
|
|||||||
"@aboutSupport": {
|
"@aboutSupport": {
|
||||||
"description": "Section for support/donation links"
|
"description": "Section for support/donation links"
|
||||||
},
|
},
|
||||||
"aboutBuyMeCoffee": "Buy me a coffee",
|
|
||||||
"@aboutBuyMeCoffee": {
|
|
||||||
"description": "Donation link"
|
|
||||||
},
|
|
||||||
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
|
|
||||||
"@aboutBuyMeCoffeeSubtitle": {
|
|
||||||
"description": "Subtitle for donation"
|
|
||||||
},
|
|
||||||
"aboutApp": "App",
|
"aboutApp": "App",
|
||||||
"@aboutApp": {
|
"@aboutApp": {
|
||||||
"description": "Section for app info"
|
"description": "Section for app info"
|
||||||
|
|||||||