Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 867ec4d125 | |||
| 164467f3a2 | |||
| 543cb45c11 | |||
| 80707fc438 | |||
| 3f42128cb9 | |||
| 591a597333 | |||
| 6388f3a5b8 | |||
| 55b75dc48d | |||
| f6cea1a683 | |||
| 8d205600b8 | |||
| aa35f60fad | |||
| b627ae1874 | |||
| 46afa6e733 | |||
| c01b189477 | |||
| 966935b677 | |||
| f2f8ca4528 | |||
| 7844bd2f42 | |||
| ac3d51e2cd | |||
| b899b54bb8 | |||
| 7a17de49b2 | |||
| 79180dd918 | |||
| ac5f74a48f | |||
| e725a7be77 | |||
| 2d22d85c49 | |||
| d960708dac | |||
| c62ad005f5 | |||
| 3edfe8e8bb | |||
| 68fa1bfdae | |||
| 6f9722e05b | |||
| bd6b23400e | |||
| 066d35967e | |||
| b6d2fea847 | |||
| 2b932cff70 | |||
| f356e53f7e | |||
| bb1ff187a3 | |||
| d99a1b1c21 | |||
| c36497e87c | |||
| 03027813c1 | |||
| 8e9d0c3e9a | |||
| 6c8813c9de | |||
| ec314eb479 | |||
| 77e4457244 | |||
| 0119db094d | |||
| 9c35515d6f | |||
| 1546d7da22 | |||
| 61720f3f2a | |||
| 7749399239 | |||
| d143b82068 | |||
| 606e7c1079 | |||
| a650632c4e | |||
| 3c118f74e4 | |||
| bc3055f6e1 | |||
| 7c86ae0b7e | |||
| 595bfb2711 | |||
| 5f39a3d52f | |||
| e7077781e6 | |||
| 42d15db4ca | |||
| c2599981d6 | |||
| a1647a41ff | |||
| bf2fc7702b | |||
| f814408702 | |||
| 6b1958bfd0 | |||
| bc120ffa76 | |||
| 5ea454a0b0 | |||
| da574f895c | |||
| 1c445e91d9 | |||
| 5d03eb0656 | |||
| becb6845a6 | |||
| be3ee3b216 | |||
| 3747674968 | |||
| ff9d088c5f | |||
| 12db11d559 | |||
| 7e1aca33a5 | |||
| 07a1c68354 | |||
| f4d7c6531f | |||
| e9ca054682 | |||
| 1069bdd0d8 | |||
| ff882a58d7 | |||
| dddc8c3d94 | |||
| 720525b67b | |||
| cc12f63d36 | |||
| 5c67553596 | |||
| 0ccda8db58 | |||
| 556c0e1db2 | |||
| 6d7b89b881 | |||
| 47777b4343 | |||
| 2eb1d2a65d | |||
| ce057c6473 | |||
| 46cfe8b632 | |||
| 2e5eff6e3d | |||
| dd506efeb6 | |||
| 9897d3102e | |||
| 8d92d22fda | |||
| b99764b1ad | |||
| 621582cf11 | |||
| b96233f90b | |||
| 88dfb88bcc | |||
| 75bfe9b3bf | |||
| 65e21a421d | |||
| 87b33dda7e | |||
| 2f097c8f6c | |||
| 8cbdea1417 | |||
| 48bdd154f6 | |||
| ae0e157c34 | |||
| 53fcdd9a47 | |||
| 3d6be3bf92 | |||
| 2d7fba3f52 | |||
| e02d8ff2cd | |||
| f8cee25958 | |||
| 99c133aae1 | |||
| f4fe74f972 |
@@ -0,0 +1,4 @@
|
|||||||
|
github: zarzet
|
||||||
|
ko_fi: zarzet
|
||||||
|
buy_me_a_coffee: zarzet
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
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
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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@v4
|
||||||
|
|
||||||
|
- name: Download Android APK
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-apk
|
||||||
|
path: ./release
|
||||||
|
|
||||||
|
- name: Download iOS IPA
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ios-ipa
|
||||||
|
path: ./release
|
||||||
|
|
||||||
|
- name: Extract changelog for version
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
VERSION_NUM=${VERSION#v}
|
||||||
|
|
||||||
|
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
|
||||||
|
# Use tr -d '\r' to handle CRLF line endings from Windows
|
||||||
|
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
|
||||||
|
|
||||||
|
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
|
||||||
|
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
|
||||||
|
|
||||||
|
if [ -z "$FULL_CHANGELOG" ]; then
|
||||||
|
CHANGELOG="See release notes on GitHub for details."
|
||||||
|
else
|
||||||
|
# Convert GitHub Markdown to Telegram HTML:
|
||||||
|
# - **text** → <b>text</b>
|
||||||
|
# - `code` → <code>code</code>
|
||||||
|
# - ### Header → <b>Header</b>
|
||||||
|
# - Escape HTML special chars first
|
||||||
|
# - Remove > blockquote prefix
|
||||||
|
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
||||||
|
sed 's/^> //' | \
|
||||||
|
sed 's/&/\&/g' | \
|
||||||
|
sed 's/</\</g' | \
|
||||||
|
sed 's/>/\>/g' | \
|
||||||
|
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
||||||
|
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||||
|
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
|
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
|
sed 's/^- /• /g' | \
|
||||||
|
sed 's/^ - / ◦ /g')
|
||||||
|
|
||||||
|
# Take first 2500 characters, then cut at last complete line
|
||||||
|
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||||
|
|
||||||
|
# Check if truncated
|
||||||
|
FULL_LEN=${#FULL_CHANGELOG}
|
||||||
|
if [ $FULL_LEN -gt 2500 ]; then
|
||||||
|
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
|
echo "DEBUG: Final changelog:"
|
||||||
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
|
- name: Send to Telegram Channel
|
||||||
|
env:
|
||||||
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
|
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
CHANGELOG=$(cat /tmp/changelog.txt)
|
||||||
|
|
||||||
|
# Find APK files
|
||||||
|
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
|
||||||
|
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
|
||||||
|
|
||||||
|
# Prepare message with changelog (HTML format)
|
||||||
|
printf '%s\n' \
|
||||||
|
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
|
||||||
|
"" \
|
||||||
|
"<b>What's New:</b>" \
|
||||||
|
"${CHANGELOG}" \
|
||||||
|
"" \
|
||||||
|
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
|
||||||
|
> /tmp/telegram_message.txt
|
||||||
|
|
||||||
|
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||||
|
|
||||||
|
# Send message first (using HTML parse mode)
|
||||||
|
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
|
||||||
|
# Use || true to ensure file uploads continue even if message fails
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
--data-urlencode "text=${MESSAGE}" \
|
||||||
|
--data-urlencode "parse_mode=HTML" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true" || true
|
||||||
|
|
||||||
|
# Upload arm64 APK to channel
|
||||||
|
if [ -f "$ARM64_APK" ]; then
|
||||||
|
echo "Uploading arm64 APK to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
-F document=@"${ARM64_APK}" \
|
||||||
|
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload arm32 APK to channel
|
||||||
|
if [ -f "$ARM32_APK" ]; then
|
||||||
|
echo "Uploading arm32 APK to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
-F document=@"${ARM32_APK}" \
|
||||||
|
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload iOS IPA to channel
|
||||||
|
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
|
||||||
|
if [ -f "$IOS_IPA" ]; then
|
||||||
|
echo "Uploading iOS IPA to Telegram..."
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
|
-F document=@"${IOS_IPA}" \
|
||||||
|
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Telegram notification sent!"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
.cursorignore
|
||||||
|
.cursorrules
|
||||||
|
|
||||||
# Kiro specs (development only)
|
# Kiro specs (development only)
|
||||||
.kiro/
|
.kiro/
|
||||||
@@ -70,3 +72,4 @@ flutter_*.log
|
|||||||
|
|
||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||||
|
identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the overall
|
||||||
|
community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||||
|
any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email address,
|
||||||
|
without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official email address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
**[zarzet](https://github.com/zarzet)**.
|
||||||
|
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series of
|
||||||
|
actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or permanent
|
||||||
|
ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||||
|
community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||||
|
[https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# Contributing to SpotiFLAC
|
||||||
|
|
||||||
|
First off, thank you for considering contributing to SpotiFLAC! 🎉
|
||||||
|
|
||||||
|
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Code of Conduct](#code-of-conduct)
|
||||||
|
- [How Can I Contribute?](#how-can-i-contribute)
|
||||||
|
- [Reporting Bugs](#reporting-bugs)
|
||||||
|
- [Suggesting Features](#suggesting-features)
|
||||||
|
- [Code Contributions](#code-contributions)
|
||||||
|
- [Translations](#translations)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Coding Guidelines](#coding-guidelines)
|
||||||
|
- [Commit Guidelines](#commit-guidelines)
|
||||||
|
- [Pull Request Process](#pull-request-process)
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
||||||
|
|
||||||
|
## How Can I Contribute?
|
||||||
|
|
||||||
|
### Reporting Bugs
|
||||||
|
|
||||||
|
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
|
||||||
|
|
||||||
|
When creating a bug report, please use the bug report template and include:
|
||||||
|
|
||||||
|
- **Clear and descriptive title**
|
||||||
|
- **Steps to reproduce** the issue
|
||||||
|
- **Expected behavior** vs **actual behavior**
|
||||||
|
- **Screenshots or screen recordings** if applicable
|
||||||
|
- **Device information** (model, OS version)
|
||||||
|
- **App version**
|
||||||
|
- **Logs** from Settings > About > View Logs
|
||||||
|
|
||||||
|
### Suggesting Features
|
||||||
|
|
||||||
|
Feature requests are welcome! Please use the feature request template and:
|
||||||
|
|
||||||
|
- **Check existing issues** to avoid duplicates
|
||||||
|
- **Describe the feature** clearly
|
||||||
|
- **Explain the use case** - why would this be useful?
|
||||||
|
- **Consider the scope** - is this a small enhancement or a major feature?
|
||||||
|
|
||||||
|
### Code Contributions
|
||||||
|
|
||||||
|
1. **Fork the repository** and create your branch from `dev`
|
||||||
|
2. **Make your changes** following our coding guidelines
|
||||||
|
3. **Test your changes** thoroughly
|
||||||
|
4. **Submit a pull request** to the `dev` branch
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
|
||||||
|
|
||||||
|
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
2. Select your language or request a new one
|
||||||
|
3. Start translating!
|
||||||
|
|
||||||
|
Translation files are located in `lib/l10n/arb/`.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Flutter SDK** 3.10.0 or higher
|
||||||
|
- **Dart SDK** 3.10.0 or higher
|
||||||
|
- **Android Studio** or **VS Code** with Flutter extensions
|
||||||
|
- **Git**
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. **Clone your fork**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
||||||
|
cd SpotiFLAC-Mobile
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add upstream remote**
|
||||||
|
```bash
|
||||||
|
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||||
|
```bash
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run the app**
|
||||||
|
```bash
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug build
|
||||||
|
flutter build apk --debug
|
||||||
|
|
||||||
|
# Release build
|
||||||
|
flutter build apk --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── l10n/ # Localization files
|
||||||
|
│ └── arb/ # ARB translation files
|
||||||
|
├── models/ # Data models
|
||||||
|
├── providers/ # Riverpod providers
|
||||||
|
├── screens/ # UI screens
|
||||||
|
│ └── settings/ # Settings sub-screens
|
||||||
|
├── services/ # Business logic services
|
||||||
|
├── theme/ # App theming
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
├── widgets/ # Reusable widgets
|
||||||
|
├── app.dart # App configuration
|
||||||
|
└── main.dart # Entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Guidelines
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
|
||||||
|
- Use meaningful variable and function names
|
||||||
|
- Keep functions small and focused
|
||||||
|
- Add comments for complex logic
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
- Use `dart format` before committing
|
||||||
|
- Maximum line length: 80 characters
|
||||||
|
- Use trailing commas for better formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart format .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
Ensure your code passes all lints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
We use **Riverpod** for state management. Follow these patterns:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use code generation with riverpod_annotation
|
||||||
|
@riverpod
|
||||||
|
class MyNotifier extends _$MyNotifier {
|
||||||
|
@override
|
||||||
|
MyState build() => MyState();
|
||||||
|
|
||||||
|
// Methods to update state
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
|
||||||
|
All user-facing strings should be localized:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Good
|
||||||
|
Text(AppLocalizations.of(context)!.downloadComplete)
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
Text('Download Complete')
|
||||||
|
```
|
||||||
|
|
||||||
|
To add new strings:
|
||||||
|
1. Add the key to `lib/l10n/arb/app_en.arb`
|
||||||
|
2. Run `flutter gen-l10n`
|
||||||
|
|
||||||
|
## Commit Guidelines
|
||||||
|
|
||||||
|
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat`: New feature
|
||||||
|
- `fix`: Bug fix
|
||||||
|
- `docs`: Documentation changes
|
||||||
|
- `style`: Code style changes (formatting, etc.)
|
||||||
|
- `refactor`: Code refactoring
|
||||||
|
- `perf`: Performance improvements
|
||||||
|
- `test`: Adding or updating tests
|
||||||
|
- `chore`: Maintenance tasks
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(download): add batch download support
|
||||||
|
fix(ui): resolve overflow on small screens
|
||||||
|
docs: update contributing guidelines
|
||||||
|
chore(deps): update flutter_riverpod to 3.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Request Process
|
||||||
|
|
||||||
|
1. **Update your fork**
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git rebase upstream/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create a feature branch**
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/my-new-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Make your changes** and commit following our guidelines
|
||||||
|
|
||||||
|
4. **Push to your fork**
|
||||||
|
```bash
|
||||||
|
git push origin feat/my-new-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Create a Pull Request**
|
||||||
|
- Target the `dev` branch
|
||||||
|
- Fill in the PR template
|
||||||
|
- Link related issues
|
||||||
|
|
||||||
|
6. **Address review feedback**
|
||||||
|
- Make requested changes
|
||||||
|
- Push additional commits
|
||||||
|
- Request re-review when ready
|
||||||
|
|
||||||
|
### PR Requirements
|
||||||
|
|
||||||
|
- [ ] Code follows project conventions
|
||||||
|
- [ ] All tests pass
|
||||||
|
- [ ] No new linting errors
|
||||||
|
- [ ] Documentation updated (if needed)
|
||||||
|
- [ ] Commit messages follow guidelines
|
||||||
|
- [ ] PR description is clear and complete
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
If you have questions, feel free to:
|
||||||
|
|
||||||
|
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
|
||||||
|
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
|
||||||
|
|
||||||
|
Thank you for contributing! 💚
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
[](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/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="icon.png" width="128" />
|
<img src="icon.png" width="128" />
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -26,12 +26,12 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
## Search Source
|
## Search Source
|
||||||
|
|
||||||
SpotiFLAC supports two search sources:
|
SpotiFLAC supports multiple search sources for finding music metadata:
|
||||||
|
|
||||||
| Source | Setup |
|
| Source | Setup |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| **Deezer** (Default) | No setup required |
|
| **Deezer** (Default) | No setup required |
|
||||||
| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
|
| **Extensions** | Install additional search providers from the Store |
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
@@ -50,7 +50,19 @@ Want to create your own extension? Check out the [Extension Development Guide](h
|
|||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||||
|
|
||||||
|
## Telegram
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://t.me/spotiflac">
|
||||||
|
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://t.me/spotiflac_chat">
|
||||||
|
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
@@ -60,25 +72,33 @@ A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling
|
|||||||
**Q: Why are some tracks downloading in lower quality?**
|
**Q: Why are some tracks downloading in lower quality?**
|
||||||
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
||||||
|
|
||||||
**Q: Can I download my Spotify playlists?**
|
**Q: Can I download playlists?**
|
||||||
A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||||
|
|
||||||
**Q: Why do I need to grant storage permission?**
|
**Q: Why do I need to grant storage permission?**
|
||||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
||||||
|
|
||||||
**Q: How do I download Daily Mix or Discover Weekly?**
|
|
||||||
A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API.
|
|
||||||
|
|
||||||
**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 by buying me a coffee. Your support helps keep development going._
|
||||||
|
|
||||||
|
[](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
|
||||||
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
|
||||||
|
|
||||||
|
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
||||||
|
|
||||||
You are solely responsible for:
|
You are solely responsible for:
|
||||||
1. Ensuring your use of this software complies with your local laws.
|
1. Ensuring your use of this software complies with your local laws.
|
||||||
@@ -86,3 +106,8 @@ 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.
|
||||||
|
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
>
|
||||||
|
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||||
|
|||||||
@@ -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,9 +15,15 @@
|
|||||||
# 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.** { *; }
|
||||||
@@ -30,15 +37,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 { *; }
|
||||||
|
|||||||
@@ -1,23 +1,155 @@
|
|||||||
package com.zarz.spotiflac
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.embedding.engine.FlutterShellArgs
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import io.flutter.plugins.GeneratedPluginRegistrant
|
||||||
import gobackend.Gobackend
|
import gobackend.Gobackend
|
||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||||
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Minimum API level we consider "safe" for Impeller (Android 10+)
|
||||||
|
private const val SAFE_API_FOR_IMPELLER = 29
|
||||||
|
|
||||||
|
// Known problematic GPU patterns (lowercase)
|
||||||
|
private val PROBLEMATIC_GPU_PATTERNS = listOf(
|
||||||
|
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
|
||||||
|
"adreno (tm) 4", // Adreno 400 series - some have issues
|
||||||
|
"mali-4", // Mali-400 series - old ARM GPUs
|
||||||
|
"mali-t6", // Mali-T600 series
|
||||||
|
"mali-t7", // Mali-T700 series (some)
|
||||||
|
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
|
||||||
|
"powervr ge8320", // PowerVR GE8320 - known issues
|
||||||
|
"gc1000", // Vivante GC1000
|
||||||
|
"gc2000", // Vivante GC2000
|
||||||
|
)
|
||||||
|
|
||||||
|
// Known problematic chipsets/hardware (lowercase)
|
||||||
|
private val PROBLEMATIC_CHIPSETS = listOf(
|
||||||
|
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
|
||||||
|
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
|
||||||
|
"mt8768", // MediaTek tablet chip
|
||||||
|
"mp0873", // MediaTek variant
|
||||||
|
"msm8974", // Snapdragon 800/801 with Adreno 330
|
||||||
|
"msm8226", // Snapdragon 400 with Adreno 305
|
||||||
|
"msm8926", // Snapdragon 400 with Adreno 305
|
||||||
|
"apq8084", // Snapdragon 805 (some issues)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Known problematic device models (lowercase)
|
||||||
|
private val PROBLEMATIC_MODELS = listOf(
|
||||||
|
"sm-t220", // Samsung Tab A7 Lite
|
||||||
|
"sm-t225", // Samsung Tab A7 Lite LTE
|
||||||
|
"hammerhead", // Nexus 5 (Adreno 330)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override Flutter shell args to disable Impeller on problematic devices.
|
||||||
|
* This is called before the Flutter engine starts.
|
||||||
|
*/
|
||||||
|
override fun getFlutterShellArgs(): FlutterShellArgs {
|
||||||
|
val args = super.getFlutterShellArgs()
|
||||||
|
|
||||||
|
if (shouldDisableImpeller()) {
|
||||||
|
// Log for debugging
|
||||||
|
android.util.Log.i("SpotiFLAC", "Legacy/problematic GPU detected: Disabling Impeller for ${Build.MODEL}")
|
||||||
|
android.util.Log.i("SpotiFLAC", "Device: ${Build.MANUFACTURER} ${Build.MODEL}, SDK: ${Build.VERSION.SDK_INT}")
|
||||||
|
android.util.Log.i("SpotiFLAC", "Hardware: ${Build.HARDWARE}, Board: ${Build.BOARD}")
|
||||||
|
|
||||||
|
// Disable Impeller, forcing Skia renderer
|
||||||
|
args.add("--enable-impeller=false")
|
||||||
|
} else {
|
||||||
|
android.util.Log.i("SpotiFLAC", "Using Impeller renderer for ${Build.MODEL}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device should use Skia instead of Impeller.
|
||||||
|
* Returns true for devices with old/problematic GPUs or old Android versions.
|
||||||
|
*/
|
||||||
|
private fun shouldDisableImpeller(): Boolean {
|
||||||
|
val hardware = Build.HARDWARE.lowercase(Locale.ROOT)
|
||||||
|
val board = Build.BOARD.lowercase(Locale.ROOT)
|
||||||
|
val model = Build.MODEL.lowercase(Locale.ROOT)
|
||||||
|
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
||||||
|
|
||||||
|
// 1. Check for explicitly problematic device models
|
||||||
|
for (problematicModel in PROBLEMATIC_MODELS) {
|
||||||
|
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
||||||
|
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for problematic chipsets
|
||||||
|
for (chipset in PROBLEMATIC_CHIPSETS) {
|
||||||
|
if (hardware.contains(chipset) || board.contains(chipset)) {
|
||||||
|
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
|
||||||
|
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
|
||||||
|
// For older Android, check GPU renderer if available
|
||||||
|
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||||
|
|
||||||
|
// Check for known problematic GPUs
|
||||||
|
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||||
|
if (gpuRenderer.contains(pattern)) {
|
||||||
|
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For very old Android (< 8.0), always use Skia as Vulkan support is spotty
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. For Android 10+, still check for known problematic GPUs
|
||||||
|
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
|
||||||
|
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
|
||||||
|
if (gpuRenderer.contains(pattern)) {
|
||||||
|
android.util.Log.i("SpotiFLAC", "Matched problematic GPU: $pattern")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to get GPU renderer string.
|
||||||
|
* Note: This may return empty on some devices before OpenGL context is created.
|
||||||
|
*/
|
||||||
|
private fun getGpuRenderer(): String {
|
||||||
|
return try {
|
||||||
|
// This might not work before GL context is created,
|
||||||
|
// but worth trying for additional detection
|
||||||
|
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
// Update the intent so receive_sharing_intent can access the new data
|
// Update the intent so receive_sharing_intent can access the new data
|
||||||
@@ -26,6 +158,7 @@ class MainActivity: FlutterActivity() {
|
|||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
GeneratedPluginRegistrant.registerWith(flutterEngine)
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -139,6 +272,28 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"checkDuplicatesBatch" -> {
|
||||||
|
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||||
|
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.checkDuplicatesBatch(outputDir, tracksJson)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"preBuildDuplicateIndex" -> {
|
||||||
|
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.preBuildDuplicateIndex(outputDir)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"invalidateDuplicateIndex" -> {
|
||||||
|
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.invalidateDuplicateIndex(outputDir)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"buildFilename" -> {
|
"buildFilename" -> {
|
||||||
val template = call.argument<String>("template") ?: ""
|
val template = call.argument<String>("template") ?: ""
|
||||||
val metadata = call.argument<String>("metadata") ?: "{}"
|
val metadata = call.argument<String>("metadata") ?: "{}"
|
||||||
@@ -158,8 +313,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.fetchLyrics(spotifyId, trackName, artistName)
|
Gobackend.fetchLyrics(spotifyId, trackName, artistName, durationMs)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -168,8 +324,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -254,9 +411,10 @@ class MainActivity: FlutterActivity() {
|
|||||||
"searchDeezerAll" -> {
|
"searchDeezerAll" -> {
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||||
|
val filter = call.argument<String>("filter") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong())
|
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -282,6 +440,13 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"getDeezerExtendedMetadata" -> {
|
||||||
|
val trackId = call.argument<String>("track_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getDeezerExtendedMetadata(trackId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"convertSpotifyToDeezer" -> {
|
"convertSpotifyToDeezer" -> {
|
||||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
@@ -297,6 +462,43 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"checkAvailabilityFromDeezerID" -> {
|
||||||
|
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.checkAvailabilityFromDeezerID(deezerTrackId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"checkAvailabilityByPlatformID" -> {
|
||||||
|
val platform = call.argument<String>("platform") ?: ""
|
||||||
|
val entityType = call.argument<String>("entity_type") ?: ""
|
||||||
|
val entityId = call.argument<String>("entity_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.checkAvailabilityByPlatformID(platform, entityType, entityId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getSpotifyIDFromDeezerTrack" -> {
|
||||||
|
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getSpotifyIDFromDeezerTrack(deezerTrackId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getTidalURLFromDeezerTrack" -> {
|
||||||
|
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getTidalURLFromDeezerTrack(deezerTrackId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getAmazonURLFromDeezerTrack" -> {
|
||||||
|
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
// Log methods
|
// Log methods
|
||||||
"getLogs" -> {
|
"getLogs" -> {
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
@@ -436,6 +638,14 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"invokeExtensionAction" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val actionName = call.argument<String>("action") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.invokeExtensionActionJSON(extensionId, actionName)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"searchTracksWithExtensions" -> {
|
"searchTracksWithExtensions" -> {
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
val limit = call.argument<Int>("limit") ?: 20
|
val limit = call.argument<Int>("limit") ?: 20
|
||||||
@@ -451,6 +661,14 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"enrichTrackWithExtension" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val trackJson = call.argument<String>("track") ?: "{}"
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.enrichTrackWithExtensionJSON(extensionId, trackJson)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"removeExtension" -> {
|
"removeExtension" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -661,6 +879,21 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
// Extension Home Feed (Explore)
|
||||||
|
"getExtensionHomeFeed" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getExtensionHomeFeedJSON(extensionId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getExtensionBrowseCategories" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getExtensionBrowseCategoriesJSON(extensionId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -668,37 +901,5 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FFmpeg method channel
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FFMPEG_CHANNEL).setMethodCallHandler { call, result ->
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
when (call.method) {
|
|
||||||
"execute" -> {
|
|
||||||
val command = call.argument<String>("command") ?: ""
|
|
||||||
val session = withContext(Dispatchers.IO) {
|
|
||||||
FFmpegKit.execute(command)
|
|
||||||
}
|
|
||||||
val returnCode = session.returnCode
|
|
||||||
val output = session.output ?: ""
|
|
||||||
result.success(mapOf(
|
|
||||||
"success" to ReturnCode.isSuccess(returnCode),
|
|
||||||
"returnCode" to (returnCode?.value ?: -1),
|
|
||||||
"output" to output
|
|
||||||
))
|
|
||||||
}
|
|
||||||
"getVersion" -> {
|
|
||||||
val session = withContext(Dispatchers.IO) {
|
|
||||||
FFmpegKit.execute("-version")
|
|
||||||
}
|
|
||||||
result.success(session.output ?: "unknown")
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("FFMPEG_ERROR", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
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,208 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
|
||||||
|
|
||||||
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
|
||||||
class FFmpegServiceIOS {
|
|
||||||
/// Execute FFmpeg command and return result
|
|
||||||
static Future<FFmpegResultIOS> _execute(String command) async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute(command);
|
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
final output = await session.getOutput() ?? '';
|
|
||||||
return FFmpegResultIOS(
|
|
||||||
success: ReturnCode.isSuccess(returnCode),
|
|
||||||
returnCode: returnCode?.getValue() ?? -1,
|
|
||||||
output: output,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('FFmpeg execute error: $e');
|
|
||||||
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert M4A (DASH segments) to FLAC
|
|
||||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
|
||||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
|
||||||
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(inputPath).delete();
|
|
||||||
} catch (_) {}
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC to MP3
|
|
||||||
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
|
||||||
final dir = File(inputPath).parent.path;
|
|
||||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
|
||||||
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
|
||||||
await Directory(outputDir).create(recursive: true);
|
|
||||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
|
||||||
|
|
||||||
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) return outputPath;
|
|
||||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC to M4A
|
|
||||||
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
|
||||||
final dir = File(inputPath).parent.path;
|
|
||||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
|
||||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
|
||||||
await Directory(outputDir).create(recursive: true);
|
|
||||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
|
||||||
|
|
||||||
String command;
|
|
||||||
if (codec == 'alac') {
|
|
||||||
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
|
||||||
} else {
|
|
||||||
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
if (result.success) return outputPath;
|
|
||||||
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed cover art to FLAC file
|
|
||||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
|
||||||
final tempOutput = '$flacPath.tmp';
|
|
||||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
return flacPath;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace file after cover embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) await tempFile.delete();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed metadata and cover art to FLAC file
|
|
||||||
/// Returns the file path on success, null on failure
|
|
||||||
static Future<String?> embedMetadata({
|
|
||||||
required String flacPath,
|
|
||||||
String? coverPath,
|
|
||||||
Map<String, String>? metadata,
|
|
||||||
}) async {
|
|
||||||
final tempOutput = '$flacPath.tmp';
|
|
||||||
|
|
||||||
// Construct command
|
|
||||||
final StringBuffer cmdBuffer = StringBuffer();
|
|
||||||
cmdBuffer.write('-i "$flacPath" ');
|
|
||||||
|
|
||||||
// Add cover input if available
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-i "$coverPath" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map audio stream
|
|
||||||
cmdBuffer.write('-map 0:a ');
|
|
||||||
|
|
||||||
// Map cover stream if available
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-map 1:0 ');
|
|
||||||
cmdBuffer.write('-c:v copy ');
|
|
||||||
cmdBuffer.write('-disposition:v attached_pic ');
|
|
||||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
|
||||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy audio codec (don't re-encode)
|
|
||||||
cmdBuffer.write('-c:a copy ');
|
|
||||||
|
|
||||||
// Add text metadata
|
|
||||||
if (metadata != null) {
|
|
||||||
metadata.forEach((key, value) {
|
|
||||||
// Sanitize value: escape double quotes
|
|
||||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
|
||||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('"$tempOutput" -y');
|
|
||||||
|
|
||||||
final command = cmdBuffer.toString();
|
|
||||||
_log.d('Executing FFmpeg command: $command');
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
return flacPath;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace file after metadata embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up temp file if exists
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) {
|
|
||||||
await tempFile.delete();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('Metadata/Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
|
||||||
static Future<bool> isAvailable() async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute('-version');
|
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
return ReturnCode.isSuccess(returnCode);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get FFmpeg version info
|
|
||||||
static Future<String?> getVersion() async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute('-version');
|
|
||||||
return await session.getOutput();
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FFmpegResultIOS {
|
|
||||||
final bool success;
|
|
||||||
final int returnCode;
|
|
||||||
final String output;
|
|
||||||
|
|
||||||
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,19 @@
|
|||||||
files:
|
files:
|
||||||
- source: /lib/l10n/arb/app_en.arb
|
- source: /lib/l10n/arb/app_en.arb
|
||||||
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
|
translation: /lib/l10n/arb/app_%locale%.arb
|
||||||
|
languages_mapping:
|
||||||
|
locale:
|
||||||
|
# Short codes for single-variant languages
|
||||||
|
de: de
|
||||||
|
es: es
|
||||||
|
fr: fr
|
||||||
|
hi: hi
|
||||||
|
id: id
|
||||||
|
ja: ja
|
||||||
|
ko: ko
|
||||||
|
nl: nl
|
||||||
|
pt: pt
|
||||||
|
ru: ru
|
||||||
|
# Full codes for Chinese variants
|
||||||
|
zh-CN: zh_CN
|
||||||
|
zh-TW: zh_TW
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/base64"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,92 +11,31 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
|
||||||
type AmazonDownloader struct {
|
type AmazonDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
regions []string // us, eu regions for DoubleDouble service
|
|
||||||
lastAPICallTime time.Time // Rate limiting: track last API call
|
|
||||||
apiCallCount int // Rate limiting: counter per minute
|
|
||||||
apiCallResetTime time.Time // Rate limiting: reset time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Amazon downloader instance for connection reuse
|
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
amazonDownloaderOnce sync.Once
|
||||||
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||||
type DoubleDoubleSubmitResponse struct {
|
type AfkarXYZResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
ID string `json:"id"`
|
Data struct {
|
||||||
|
DirectLink string `json:"direct_link"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
|
|
||||||
type DoubleDoubleStatusResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
FriendlyStatus string `json:"friendlyStatus"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Current struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
} `json:"current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// amazonArtistsMatch checks if the artist names are similar enough
|
|
||||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if one contains the other
|
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
|
||||||
// assume they're the same artist with different transliteration
|
|
||||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
|
||||||
foundASCII := amazonIsASCIIString(foundArtist)
|
|
||||||
if expectedASCII != foundASCII {
|
|
||||||
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// amazonIsASCIIString checks if a string contains only ASCII characters
|
|
||||||
func amazonIsASCIIString(s string) bool {
|
func amazonIsASCIIString(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if r > 127 {
|
if r > 127 {
|
||||||
@@ -107,246 +45,68 @@ func amazonIsASCIIString(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
amazonDownloaderOnce.Do(func() {
|
amazonDownloaderOnce.Do(func() {
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
|
||||||
apiCallResetTime: time.Now(),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalAmazonDownloader
|
return globalAmazonDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForRateLimit implements rate limiting similar to PC version
|
// downloadFromAfkarXYZ downloads a track using AfkarXYZ API
|
||||||
// Max 9 requests per minute with 7 second delay between requests
|
// Returns: downloadURL, fileName, error
|
||||||
func (a *AmazonDownloader) waitForRateLimit() {
|
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
|
||||||
amazonRateLimitMu.Lock()
|
// AfkarXYZ API endpoint
|
||||||
defer amazonRateLimitMu.Unlock()
|
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||||
|
|
||||||
now := time.Now()
|
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||||
|
|
||||||
// Reset counter every minute
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
if err != nil {
|
||||||
a.apiCallCount = 0
|
return "", "", fmt.Errorf("failed to create request: %w", err)
|
||||||
a.apiCallResetTime = now
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we've hit the limit (9 requests per minute), wait until next minute
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
if a.apiCallCount >= 9 {
|
|
||||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
resp, err := a.client.Do(req)
|
||||||
if waitTime > 0 {
|
if err != nil {
|
||||||
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
|
||||||
time.Sleep(waitTime)
|
}
|
||||||
a.apiCallCount = 0
|
defer resp.Body.Close()
|
||||||
a.apiCallResetTime = time.Now()
|
|
||||||
}
|
if resp.StatusCode != 200 {
|
||||||
|
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add delay between requests (7 seconds like PC version)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if !a.lastAPICallTime.IsZero() {
|
if err != nil {
|
||||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
return "", "", fmt.Errorf("failed to read response: %w", err)
|
||||||
minDelay := 7 * time.Second
|
|
||||||
if timeSinceLastCall < minDelay {
|
|
||||||
waitTime := minDelay - timeSinceLastCall
|
|
||||||
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tracking
|
var apiResp AfkarXYZResponse
|
||||||
a.lastAPICallTime = time.Now()
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
a.apiCallCount++
|
return "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !apiResp.Success || apiResp.Data.DirectLink == "" {
|
||||||
|
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := apiResp.Data.FileName
|
||||||
|
if fileName == "" {
|
||||||
|
fileName = "track.flac"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize filename
|
||||||
|
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||||
|
fileName = reg.ReplaceAllString(fileName, "")
|
||||||
|
|
||||||
|
GoLog("[Amazon] AfkarXYZ returned: %s (%.2f MB)\n", fileName, float64(apiResp.Data.FileSize)/(1024*1024))
|
||||||
|
|
||||||
|
return apiResp.Data.DirectLink, fileName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
|
||||||
// Uses same service as PC version (doubledouble.top)
|
|
||||||
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
|
||||||
// DoubleDouble service regions (same as PC)
|
|
||||||
// Format: https://{region}.doubledouble.top
|
|
||||||
var apis []string
|
|
||||||
for _, region := range a.regions {
|
|
||||||
apis = append(apis, fmt.Sprintf("https://%s.doubledouble.top", region))
|
|
||||||
}
|
|
||||||
return apis
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
|
||||||
// This uses submit → poll → download mechanism
|
|
||||||
// Internal function - not exported to gomobile
|
|
||||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) {
|
|
||||||
var lastError error
|
|
||||||
|
|
||||||
for _, region := range a.regions {
|
|
||||||
GoLog("[Amazon] Trying region: %s...\n", region)
|
|
||||||
|
|
||||||
// Build base URL for DoubleDouble service
|
|
||||||
// Decode base64 service URL (same as PC)
|
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
|
||||||
|
|
||||||
// Step 1: Submit download request with rate limiting
|
|
||||||
encodedURL := url.QueryEscape(amazonURL)
|
|
||||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
|
||||||
|
|
||||||
// Apply rate limiting before request (like PC version)
|
|
||||||
a.waitForRateLimit()
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", submitURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
fmt.Println("[Amazon] Submitting download request...")
|
|
||||||
|
|
||||||
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
|
||||||
var resp *http.Response
|
|
||||||
maxRetries := 3
|
|
||||||
for retry := 0; retry < maxRetries; retry++ {
|
|
||||||
resp, err = a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == 429 { // Too Many Requests
|
|
||||||
resp.Body.Close()
|
|
||||||
if retry < maxRetries-1 {
|
|
||||||
waitTime := 15 * time.Second
|
|
||||||
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lastError = fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
resp.Body.Close()
|
|
||||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success - break retry loop
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || lastError != nil {
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var submitResp DoubleDoubleSubmitResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
if !submitResp.Success || submitResp.ID == "" {
|
|
||||||
lastError = fmt.Errorf("submit request failed")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadID := submitResp.ID
|
|
||||||
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
|
||||||
|
|
||||||
// Step 2: Poll for completion
|
|
||||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
|
||||||
fmt.Println("[Amazon] Waiting for download to complete...")
|
|
||||||
|
|
||||||
maxWait := 300 * time.Second // 5 minutes max wait
|
|
||||||
elapsed := time.Duration(0)
|
|
||||||
pollInterval := 3 * time.Second
|
|
||||||
|
|
||||||
for elapsed < maxWait {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
elapsed += pollInterval
|
|
||||||
|
|
||||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
statusReq.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
statusResp, err := a.client.Do(statusReq)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("\r[Amazon] Status check failed, retrying...")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if statusResp.StatusCode != 200 {
|
|
||||||
statusResp.Body.Close()
|
|
||||||
fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var status DoubleDoubleStatusResponse
|
|
||||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
|
||||||
statusResp.Body.Close()
|
|
||||||
fmt.Printf("\r[Amazon] Invalid JSON response, retrying...")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
statusResp.Body.Close()
|
|
||||||
|
|
||||||
if status.Status == "done" {
|
|
||||||
fmt.Println("\n[Amazon] Download ready!")
|
|
||||||
|
|
||||||
// Build download URL
|
|
||||||
fileURL := status.URL
|
|
||||||
if strings.HasPrefix(fileURL, "./") {
|
|
||||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
|
||||||
} else if strings.HasPrefix(fileURL, "/") {
|
|
||||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
trackName := status.Current.Name
|
|
||||||
artist := status.Current.Artist
|
|
||||||
|
|
||||||
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
|
||||||
return fileURL, trackName, artist, nil
|
|
||||||
|
|
||||||
} else if status.Status == "error" {
|
|
||||||
errorMsg := status.FriendlyStatus
|
|
||||||
if errorMsg == "" {
|
|
||||||
errorMsg = "Unknown error"
|
|
||||||
}
|
|
||||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
// Still processing
|
|
||||||
friendlyStatus := status.FriendlyStatus
|
|
||||||
if friendlyStatus == "" {
|
|
||||||
friendlyStatus = status.Status
|
|
||||||
}
|
|
||||||
fmt.Printf("\r[Amazon] %s...", friendlyStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if elapsed >= maxWait {
|
|
||||||
lastError = fmt.Errorf("download timeout")
|
|
||||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastError != nil {
|
|
||||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -383,7 +143,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -393,16 +152,13 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
written, err = io.Copy(pw, resp.Body)
|
written, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +166,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
// Check for any errors
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
@@ -433,7 +188,7 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,29 +206,22 @@ type AmazonDownloadResult struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
// downloadFromAmazon uses AfkarXYZ API to download from Amazon Music
|
||||||
// Uses DoubleDouble service (same as PC version)
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from SongLink
|
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
var availability *TrackAvailability
|
var availability *TrackAvailability
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
|
||||||
// Extract Deezer ID and use Deezer-based lookup
|
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
|
||||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
} else if req.SpotifyID != "" {
|
} else if req.SpotifyID != "" {
|
||||||
// Use Spotify ID
|
|
||||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
} else {
|
} else {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||||
@@ -487,30 +235,21 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory if needed
|
|
||||||
if req.OutputDir != "." {
|
if req.OutputDir != "." {
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download using DoubleDouble service (same as PC)
|
// Download using AfkarXYZ API
|
||||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify artist matches
|
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
|
||||||
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log match found
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
@@ -521,7 +260,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
filename = sanitizeFilename(filename) + ".flac"
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
outputPath := filepath.Join(req.OutputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
@@ -538,6 +276,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
req.TrackName,
|
req.TrackName,
|
||||||
req.ArtistName,
|
req.ArtistName,
|
||||||
req.EmbedLyrics,
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -552,26 +291,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
|
||||||
if trackName != "" && artistName != "" {
|
|
||||||
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read existing metadata from downloaded file BEFORE embedding
|
|
||||||
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
|
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
if metaErr == nil && existingMeta != nil {
|
||||||
// Use file metadata if it has valid track/disc numbers and request doesn't have them
|
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||||
@@ -582,8 +311,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
// Embed metadata using Spotify data
|
||||||
// But preserve track/disc numbers from file if they were better
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
@@ -594,6 +322,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
|
Genre: req.Genre,
|
||||||
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
// Use cover data from parallel fetch
|
||||||
@@ -604,25 +335,38 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed lyrics from parallel fetch
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
lyricsMode := req.LyricsMode
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if lyricsMode == "" {
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
lyricsMode = "embed" // default
|
||||||
} else {
|
}
|
||||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
|
||||||
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
|
GoLog("[Amazon] Saving external LRC file...\n")
|
||||||
|
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
|
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||||
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if req.EmbedLyrics {
|
} else if req.EmbedLyrics {
|
||||||
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||||
|
|
||||||
// Read actual quality from the downloaded FLAC file
|
|
||||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
|
||||||
quality, err := GetAudioQuality(outputPath)
|
quality, err := GetAudioQuality(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
@@ -630,8 +374,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read metadata from file AFTER embedding to get accurate values
|
|
||||||
// This ensures we return what's actually in the file
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
@@ -639,7 +381,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
actualTrackNum = finalMeta.TrackNumber
|
actualTrackNum = finalMeta.TrackNumber
|
||||||
actualDiscNum = finalMeta.DiscNumber
|
actualDiscNum = finalMeta.DiscNumber
|
||||||
if finalMeta.Date != "" {
|
if finalMeta.Date != "" {
|
||||||
// Use date from file if available
|
|
||||||
req.ReleaseDate = finalMeta.Date
|
req.ReleaseDate = finalMeta.Date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ func cancelDownload(itemID string) {
|
|||||||
}
|
}
|
||||||
cancelMu.Unlock()
|
cancelMu.Unlock()
|
||||||
|
|
||||||
// Hide progress for cancelled items.
|
|
||||||
RemoveItemProgress(itemID)
|
RemoveItemProgress(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Spotify image size codes (same as PC version)
|
|
||||||
const (
|
const (
|
||||||
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
|
spotifySize300 = "ab67616d00001e02"
|
||||||
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
|
spotifySize640 = "ab67616d0000b273"
|
||||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
spotifySizeMax = "ab67616d000082c1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
// Same logic as PC version for consistency
|
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -23,8 +24,6 @@ func convertSmallToMedium(imageURL string) string {
|
|||||||
return imageURL
|
return imageURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
|
||||||
// This avoids file permission issues on Android
|
|
||||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
return nil, fmt.Errorf("no cover URL provided")
|
return nil, fmt.Errorf("no cover URL provided")
|
||||||
@@ -32,20 +31,19 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
|
|
||||||
GoLog("[Cover] Original URL: %s", coverURL)
|
GoLog("[Cover] Original URL: %s", coverURL)
|
||||||
|
|
||||||
// First upgrade small (300) to medium (640) - always do this
|
|
||||||
downloadURL := convertSmallToMedium(coverURL)
|
downloadURL := convertSmallToMedium(coverURL)
|
||||||
if downloadURL != coverURL {
|
if downloadURL != coverURL {
|
||||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then upgrade to max quality if requested
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if maxURL != downloadURL {
|
if maxURL != downloadURL {
|
||||||
downloadURL = maxURL
|
downloadURL = maxURL
|
||||||
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
|
// Log already printed by upgradeToMaxQuality for Deezer
|
||||||
} else {
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
GoLog("[Cover] Max resolution not available, using 640x640")
|
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +51,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
|
|
||||||
// Create request with User-Agent (required by Spotify CDN)
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -74,8 +71,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate approximate resolution from file size
|
|
||||||
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
|
|
||||||
sizeKB := len(data) / 1024
|
sizeKB := len(data) / 1024
|
||||||
var resolution string
|
var resolution string
|
||||||
if sizeKB > 200 {
|
if sizeKB > 200 {
|
||||||
@@ -90,23 +85,33 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
|
||||||
// Same logic as PC version - directly replaces 640x640 size code with max resolution
|
|
||||||
// No HEAD verification needed - Spotify CDN always serves max resolution if available
|
|
||||||
func upgradeToMaxQuality(coverURL string) string {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify image URLs can be upgraded by changing the size parameter
|
// Spotify CDN upgrade
|
||||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
|
||||||
// ab67616d0000b273 = 640x640
|
|
||||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
|
||||||
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deezer CDN upgrade
|
||||||
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
|
return upgradeDeezerCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
func upgradeDeezerCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace any size pattern with 1800x1800
|
||||||
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -22,27 +22,23 @@ const (
|
|||||||
|
|
||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
// Parallel ISRC fetching settings
|
deezerMaxParallelISRC = 10
|
||||||
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeezerClient handles Deezer API interactions (no auth required)
|
|
||||||
type DeezerClient struct {
|
type DeezerClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
searchCache map[string]*cacheEntry
|
searchCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry
|
albumCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry
|
artistCache map[string]*cacheEntry
|
||||||
isrcCache map[string]string // trackID -> ISRC cache
|
isrcCache map[string]string
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
var (
|
var (
|
||||||
deezerClient *DeezerClient
|
deezerClient *DeezerClient
|
||||||
deezerClientOnce sync.Once
|
deezerClientOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetDeezerClient returns singleton Deezer client
|
|
||||||
func GetDeezerClient() *DeezerClient {
|
func GetDeezerClient() *DeezerClient {
|
||||||
deezerClientOnce.Do(func() {
|
deezerClientOnce.Do(func() {
|
||||||
deezerClient = &DeezerClient{
|
deezerClient = &DeezerClient{
|
||||||
@@ -56,7 +52,6 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
return deezerClient
|
return deezerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer API response types
|
|
||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -65,7 +60,7 @@ type deezerTrack struct {
|
|||||||
DiskNumber int `json:"disk_number"`
|
DiskNumber int `json:"disk_number"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
ReleaseDate string `json:"release_date"`
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Album deezerAlbumSimple `json:"album"`
|
Album deezerAlbumSimple `json:"album"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
@@ -88,8 +83,8 @@ type deezerAlbumSimple struct {
|
|||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
ReleaseDate string `json:"release_date"`
|
||||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
RecordType string `json:"record_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
@@ -113,7 +108,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
albumImage = track.Album.Cover
|
albumImage = track.Album.Cover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find release date
|
|
||||||
releaseDate := track.ReleaseDate
|
releaseDate := track.ReleaseDate
|
||||||
if releaseDate == "" {
|
if releaseDate == "" {
|
||||||
releaseDate = track.Album.ReleaseDate
|
releaseDate = track.Album.ReleaseDate
|
||||||
@@ -135,16 +129,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type deezerGenre struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type deezerAlbumFull struct {
|
type deezerAlbumFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
RecordType string `json:"record_type"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Genres struct {
|
||||||
|
Data []deezerGenre `json:"data"`
|
||||||
|
} `json:"genres"`
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
@@ -179,12 +182,41 @@ type deezerPlaylistFull struct {
|
|||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAll searches for tracks and artists on Deezer
|
|
||||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
// 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) (*SearchAllResult, error) {
|
// filter can be: "" (all), "track", "artist", "album", "playlist"
|
||||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||||
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
albumLimit := 5 // Same as artistLimit for consistency
|
||||||
|
playlistLimit := 5
|
||||||
|
|
||||||
|
// When filter is specified, increase limits for that type only
|
||||||
|
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() {
|
||||||
@@ -195,73 +227,194 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
result := &SearchAllResult{
|
result := &SearchAllResult{
|
||||||
Tracks: make([]TrackMetadata, 0),
|
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||||
Artists: make([]SearchArtistResult, 0),
|
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||||
|
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||||
|
Playlists: make([]SearchPlaylistResult, 0, playlistLimit),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search tracks - NO ISRC fetch for performance
|
// Search tracks - NO ISRC fetch for performance
|
||||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
if trackLimit > 0 {
|
||||||
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
|
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||||
|
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
Error *struct {
|
Error *struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
GoLog("[Deezer] Track search failed: %v\n", err)
|
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if trackResp.Error != nil {
|
if trackResp.Error != nil {
|
||||||
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||||
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||||
|
|
||||||
for _, track := range trackResp.Data {
|
for _, track := range trackResp.Data {
|
||||||
// Convert directly without fetching ISRC - much faster
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search artists
|
// Search artists
|
||||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
if artistLimit > 0 {
|
||||||
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
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 {
|
var artistResp struct {
|
||||||
Data []deezerArtist `json:"data"`
|
Data []deezerArtist `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, artistURL, &artistResp); err == nil {
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
if artistResp.Error != 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)
|
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||||
for _, artist := range artistResp.Data {
|
for _, artist := range artistResp.Data {
|
||||||
result.Artists = append(result.Artists, SearchArtistResult{
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
Name: artist.Name,
|
Name: artist.Name,
|
||||||
Images: c.getBestArtistImage(artist),
|
Images: c.getBestArtistImage(artist),
|
||||||
Followers: artist.NbFan,
|
Followers: artist.NbFan,
|
||||||
Popularity: 0,
|
Popularity: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
GoLog("[Deezer] Artist search failed: %v\n", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
// Search albums
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search playlists
|
||||||
|
if playlistLimit > 0 {
|
||||||
|
playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit)
|
||||||
|
GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL)
|
||||||
|
|
||||||
|
var playlistResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
User struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"user"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, playlistURL, &playlistResp); err == nil {
|
||||||
|
if playlistResp.Error != nil {
|
||||||
|
GoLog("[Deezer] Playlist API error: type=%s, code=%d, message=%s\n", playlistResp.Error.Type, playlistResp.Error.Code, playlistResp.Error.Message)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Got %d playlists from API\n", len(playlistResp.Data))
|
||||||
|
for _, playlist := range playlistResp.Data {
|
||||||
|
pictureURL := playlist.PictureXL
|
||||||
|
if pictureURL == "" {
|
||||||
|
pictureURL = playlist.PictureBig
|
||||||
|
}
|
||||||
|
if pictureURL == "" {
|
||||||
|
pictureURL = playlist.PictureMedium
|
||||||
|
}
|
||||||
|
if pictureURL == "" {
|
||||||
|
pictureURL = playlist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Playlists = append(result.Playlists, SearchPlaylistResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", playlist.ID),
|
||||||
|
Name: playlist.Title,
|
||||||
|
Owner: playlist.User.Name,
|
||||||
|
Images: pictureURL,
|
||||||
|
TotalTracks: playlist.NbTracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Playlist search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
|
||||||
|
|
||||||
// Cache result
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -286,7 +439,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAlbum fetches album with tracks
|
|
||||||
// ISRC is fetched in parallel for better performance
|
// 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()
|
||||||
@@ -313,28 +465,80 @@ 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
|
||||||
|
for _, g := range album.Genres.Data {
|
||||||
|
if g.Name != "" {
|
||||||
|
genres = append(genres, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
genreStr := strings.Join(genres, ", ")
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
Name: album.Title,
|
Name: album.Title,
|
||||||
ReleaseDate: album.ReleaseDate,
|
ReleaseDate: album.ReleaseDate,
|
||||||
Artists: artistName,
|
Artists: artistName,
|
||||||
|
ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID),
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
|
Genre: genreStr, // From Deezer album
|
||||||
|
Label: album.Label, // From Deezer album
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ISRCs in parallel
|
// Fetch all tracks with pagination (Deezer default limit is 25)
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
allTracks := album.Tracks.Data
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
// If album has more tracks than returned, fetch remaining pages
|
||||||
|
if album.NbTracks > len(allTracks) {
|
||||||
|
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))
|
||||||
// Normalize record_type (Deezer uses "compile" instead of "compilation")
|
// Normalize record_type (Deezer uses "compile" instead of "compilation")
|
||||||
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]
|
||||||
|
|
||||||
|
// Use track position from API, fallback to index+1 if not provided
|
||||||
|
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,
|
||||||
@@ -344,7 +548,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,
|
||||||
@@ -369,7 +573,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArtist fetches artist with albums
|
|
||||||
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
@@ -455,8 +658,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPlaylist fetches playlist with tracks
|
|
||||||
// ISRC is fetched in parallel for better performance
|
|
||||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||||
|
|
||||||
@@ -479,11 +680,45 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
info.Owner.Name = playlist.Title
|
info.Owner.Name = playlist.Title
|
||||||
info.Owner.Images = playlistImage
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
// Fetch ISRCs in parallel
|
// Fetch all tracks with pagination (Deezer default limit is 25)
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
allTracks := playlist.Tracks.Data
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
// If playlist has more tracks than returned, fetch remaining pages
|
||||||
for _, track := range playlist.Tracks.Data {
|
if playlist.NbTracks > len(allTracks) {
|
||||||
|
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
|
||||||
@@ -518,15 +753,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchByISRC searches for a track by ISRC using direct endpoint
|
|
||||||
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||||
// Use direct ISRC endpoint (API 2.0)
|
|
||||||
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
|
||||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||||
|
|
||||||
var track deezerTrack
|
var track deezerTrack
|
||||||
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||||
// Fallback to search if direct endpoint fails
|
|
||||||
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
|
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
@@ -541,7 +772,6 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we got a valid response (ID > 0)
|
|
||||||
if track.ID == 0 {
|
if track.ID == 0 {
|
||||||
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
}
|
}
|
||||||
@@ -561,14 +791,24 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
|||||||
|
|
||||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
||||||
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string, len(tracks))
|
||||||
var resultMu sync.Mutex
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
// First, check cache for existing ISRCs
|
|
||||||
var tracksToFetch []deezerTrack
|
var tracksToFetch []deezerTrack
|
||||||
|
var directISRCs map[string]string
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
for _, track := range tracks {
|
for _, track := range tracks {
|
||||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
if track.ISRC != "" {
|
||||||
|
result[trackIDStr] = track.ISRC
|
||||||
|
if _, ok := c.isrcCache[trackIDStr]; !ok {
|
||||||
|
if directISRCs == nil {
|
||||||
|
directISRCs = make(map[string]string)
|
||||||
|
}
|
||||||
|
directISRCs[trackIDStr] = track.ISRC
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if isrc, ok := c.isrcCache[trackIDStr]; ok {
|
if isrc, ok := c.isrcCache[trackIDStr]; ok {
|
||||||
result[trackIDStr] = isrc
|
result[trackIDStr] = isrc
|
||||||
} else {
|
} else {
|
||||||
@@ -576,6 +816,13 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
if len(directISRCs) > 0 {
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
for trackIDStr, isrc := range directISRCs {
|
||||||
|
c.isrcCache[trackIDStr] = isrc
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
if len(tracksToFetch) == 0 {
|
if len(tracksToFetch) == 0 {
|
||||||
return result
|
return result
|
||||||
@@ -590,7 +837,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
go func(t deezerTrack) {
|
go func(t deezerTrack) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
// Acquire semaphore
|
|
||||||
select {
|
select {
|
||||||
case sem <- struct{}{}:
|
case sem <- struct{}{}:
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
@@ -619,10 +865,8 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrackISRC fetches ISRC for a single track (with caching)
|
|
||||||
// Use this when you need ISRC for download
|
// Use this when you need ISRC for download
|
||||||
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if isrc, ok := c.isrcCache[trackID]; ok {
|
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -630,13 +874,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Fetch from API
|
|
||||||
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackID] = fullTrack.ISRC
|
c.isrcCache[trackID] = fullTrack.ISRC
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
@@ -683,6 +925,101 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
return album.Cover
|
return album.Cover
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlbumExtendedMetadata struct {
|
||||||
|
Genre string // Comma-separated list of genres
|
||||||
|
Label string // Record label name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uses the album ID from a track to fetch extended metadata
|
||||||
|
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||||
|
if albumID == "" {
|
||||||
|
return nil, fmt.Errorf("empty album ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("album_meta:%s", albumID)
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumExtendedMetadata), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||||
|
|
||||||
|
var album deezerAlbumFull
|
||||||
|
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var genres []string
|
||||||
|
for _, g := range album.Genres.Data {
|
||||||
|
if g.Name != "" {
|
||||||
|
genres = append(genres, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AlbumExtendedMetadata{
|
||||||
|
Genre: strings.Join(genres, ", "),
|
||||||
|
Label: album.Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrackAlbumID fetches the album ID for a Deezer track
|
||||||
|
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
|
||||||
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d", track.Album.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get album ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.GetAlbumExtendedMetadata(ctx, albumID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label)
|
||||||
|
func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||||
|
if isrc == "" {
|
||||||
|
return nil, fmt.Errorf("empty ISRC")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, search for track by ISRC
|
||||||
|
track, err := c.SearchByISRC(ctx, isrc)
|
||||||
|
if err != nil {
|
||||||
|
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:")
|
||||||
|
|
||||||
|
if deezerID == "" {
|
||||||
|
return nil, fmt.Errorf("track found but no Deezer ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then fetch extended metadata using the Deezer track ID
|
||||||
|
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -727,7 +1064,6 @@ func parseDeezerURL(input string) (string, string, error) {
|
|||||||
|
|
||||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
|
||||||
// Skip language prefix if present (e.g., /en/, /fr/)
|
|
||||||
if len(parts) > 0 && len(parts[0]) == 2 {
|
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||||
parts = parts[1:]
|
parts = parts[1:]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,30 +18,45 @@ type ISRCIndex struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global ISRC index cache (per output directory)
|
|
||||||
var (
|
var (
|
||||||
isrcIndexCache = make(map[string]*ISRCIndex)
|
isrcIndexCache = make(map[string]*ISRCIndex)
|
||||||
isrcIndexCacheMu sync.RWMutex
|
isrcIndexCacheMu sync.RWMutex
|
||||||
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
|
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
|
||||||
|
isrcIndexTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetISRCIndex returns or builds an ISRC index for the given directory
|
// 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
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
idx, exists := isrcIndexCache[outputDir]
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
isrcIndexCacheMu.RUnlock()
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
// Return cached index if still valid
|
|
||||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build new index
|
// Slow path: need to build index
|
||||||
|
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||||
|
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||||
|
mu := buildLock.(*sync.Mutex)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check cache after acquiring lock (another goroutine may have built it)
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists = isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
return buildISRCIndex(outputDir)
|
return buildISRCIndex(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||||
// Same implementation as PC version for consistency
|
|
||||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
idx := &ISRCIndex{
|
idx := &ISRCIndex{
|
||||||
index: make(map[string]string),
|
index: make(map[string]string),
|
||||||
@@ -56,7 +71,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
fileCount := 0
|
fileCount := 0
|
||||||
|
|
||||||
// Walk directory - only check .flac files
|
|
||||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
@@ -67,13 +81,11 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read ISRC from file
|
|
||||||
metadata, err := ReadMetadata(path)
|
metadata, err := ReadMetadata(path)
|
||||||
if err != nil || metadata.ISRC == "" {
|
if err != nil || metadata.ISRC == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in index (uppercase for case-insensitive matching)
|
|
||||||
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||||
fileCount++
|
fileCount++
|
||||||
return nil
|
return nil
|
||||||
@@ -82,7 +94,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
// Cache the index
|
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
isrcIndexCache[outputDir] = idx
|
isrcIndexCache[outputDir] = idx
|
||||||
isrcIndexCacheMu.Unlock()
|
isrcIndexCacheMu.Unlock()
|
||||||
@@ -90,7 +101,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookup checks if an ISRC exists in the index (internal, returns bool)
|
|
||||||
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -148,7 +158,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use index for fast lookup
|
|
||||||
idx := GetISRCIndex(outputDir)
|
idx := GetISRCIndex(outputDir)
|
||||||
filePath, exists := idx.lookup(isrc)
|
filePath, exists := idx.lookup(isrc)
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -165,7 +174,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||||
// Returns the filepath if exists, empty string if not
|
|
||||||
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||||
return filepath, nil
|
return filepath, nil
|
||||||
@@ -189,11 +197,7 @@ type FileExistenceResult struct {
|
|||||||
ArtistName string `json:"artist_name,omitempty"`
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckFilesExistParallel checks if multiple files exist in parallel
|
|
||||||
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
|
||||||
// Same implementation as PC version for consistency
|
|
||||||
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||||
// Parse input JSON
|
|
||||||
var tracks []struct {
|
var tracks []struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
TrackName string `json:"track_name"`
|
TrackName string `json:"track_name"`
|
||||||
@@ -205,10 +209,8 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
|
|
||||||
results := make([]FileExistenceResult, len(tracks))
|
results := make([]FileExistenceResult, len(tracks))
|
||||||
|
|
||||||
// Build ISRC index from output directory (scan once)
|
|
||||||
isrcIdx := GetISRCIndex(outputDir)
|
isrcIdx := GetISRCIndex(outputDir)
|
||||||
|
|
||||||
// Check each track against the index (parallel)
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -239,7 +241,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Return results as JSON
|
|
||||||
resultJSON, err := json.Marshal(results)
|
resultJSON, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to marshal results: %w", err)
|
return "", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
@@ -260,7 +261,6 @@ func PreBuildISRCIndex(outputDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
||||||
// This avoids rebuilding the entire index
|
|
||||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||||
if outputDir == "" || isrc == "" || filePath == "" {
|
if outputDir == "" || isrc == "" || filePath == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseSpotifyURL parses and validates a Spotify URL
|
|
||||||
// Returns JSON with type (track/album/playlist) and ID
|
|
||||||
func ParseSpotifyURL(url string) (string, error) {
|
func ParseSpotifyURL(url string) (string, error) {
|
||||||
parsed, err := parseSpotifyURI(url)
|
parsed, err := parseSpotifyURI(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,19 +32,14 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
|
||||||
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||||
SetSpotifyCredentials(clientID, clientSecret)
|
SetSpotifyCredentials(clientID, clientSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckSpotifyCredentials checks if Spotify credentials are configured
|
|
||||||
// Returns true if credentials are available (custom or env vars)
|
|
||||||
func CheckSpotifyCredentials() bool {
|
func CheckSpotifyCredentials() bool {
|
||||||
return HasSpotifyCredentials()
|
return HasSpotifyCredentials()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
|
||||||
// Returns JSON with track/album/playlist data
|
|
||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -68,8 +61,6 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchSpotify searches for tracks on Spotify
|
|
||||||
// Returns JSON array of track results
|
|
||||||
func SearchSpotify(query string, limit int) (string, error) {
|
func SearchSpotify(query string, limit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -91,8 +82,6 @@ func SearchSpotify(query string, limit int) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchSpotifyAll searches for tracks and artists on Spotify
|
|
||||||
// Returns JSON with tracks and artists arrays
|
|
||||||
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -114,8 +103,6 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAvailability checks track availability on streaming services
|
|
||||||
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
|
||||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
@@ -131,7 +118,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadRequest represents a download request from Flutter
|
|
||||||
type DownloadRequest struct {
|
type DownloadRequest struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
@@ -143,49 +129,51 @@ type DownloadRequest struct {
|
|||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
FilenameFormat string `json:"filename_format"`
|
FilenameFormat string `json:"filename_format"`
|
||||||
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
Quality string `json:"quality"`
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"`
|
||||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
DurationMS int `json:"duration_ms"`
|
||||||
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
|
Source string `json:"source"`
|
||||||
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch
|
Genre string `json:"genre,omitempty"`
|
||||||
TidalID string `json:"tidal_id,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
QobuzID string `json:"qobuz_id,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
DeezerID string `json:"deezer_id,omitempty"`
|
TidalID string `json:"tidal_id,omitempty"`
|
||||||
|
QobuzID string `json:"qobuz_id,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
|
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResponse represents the result of a download
|
// DownloadResponse represents the result of a download
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
// Actual quality info from the source
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
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"`
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
AlbumArtist string `json:"album_artist,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"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
// If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata)
|
Label string `json:"label,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResult is a generic result type for all downloaders
|
|
||||||
// DownloadResult is a generic result type for all downloaders
|
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
@@ -199,9 +187,6 @@ type DownloadResult struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadTrack downloads a track from the specified service
|
|
||||||
// requestJSON is a JSON string of DownloadRequest
|
|
||||||
// Returns JSON string of DownloadResponse
|
|
||||||
func DownloadTrack(requestJSON string) (string, error) {
|
func DownloadTrack(requestJSON string) (string, error) {
|
||||||
var req DownloadRequest
|
var req DownloadRequest
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
@@ -215,7 +200,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// Add output directory to allowed download dirs for extensions
|
|
||||||
if req.OutputDir != "" {
|
if req.OutputDir != "" {
|
||||||
AddAllowedDownloadDir(req.OutputDir)
|
AddAllowedDownloadDir(req.OutputDir)
|
||||||
}
|
}
|
||||||
@@ -283,10 +267,8 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
return errorResponse(err.Error())
|
return errorResponse(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
// Read actual quality from existing file
|
|
||||||
quality, qErr := GetAudioQuality(actualPath)
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -312,7 +294,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -342,27 +323,22 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadWithFallback tries to download from services in order
|
|
||||||
// Starts with the preferred service from request, then tries others
|
|
||||||
func DownloadWithFallback(requestJSON string) (string, error) {
|
func DownloadWithFallback(requestJSON string) (string, error) {
|
||||||
var req DownloadRequest
|
var req DownloadRequest
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim whitespace from string fields to prevent filename/path issues
|
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// Add output directory to allowed download dirs for extensions
|
|
||||||
if req.OutputDir != "" {
|
if req.OutputDir != "" {
|
||||||
AddAllowedDownloadDir(req.OutputDir)
|
AddAllowedDownloadDir(req.OutputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
|
||||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
@@ -371,7 +347,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||||
|
|
||||||
// Create ordered list: preferred first, then others
|
|
||||||
services := []string{preferredService}
|
services := []string{preferredService}
|
||||||
for _, s := range allServices {
|
for _, s := range allServices {
|
||||||
if s != preferredService {
|
if s != preferredService {
|
||||||
@@ -455,10 +430,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
// Read actual quality from existing file
|
|
||||||
quality, qErr := GetAudioQuality(actualPath)
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -484,7 +457,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -519,58 +491,44 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadProgress returns current download progress
|
|
||||||
func GetDownloadProgress() string {
|
func GetDownloadProgress() string {
|
||||||
progress := getProgress()
|
progress := getProgress()
|
||||||
jsonBytes, _ := json.Marshal(progress)
|
jsonBytes, _ := json.Marshal(progress)
|
||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
|
|
||||||
func GetAllDownloadProgress() string {
|
func GetAllDownloadProgress() string {
|
||||||
return GetMultiProgress()
|
return GetMultiProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitItemProgress initializes progress tracking for a download item
|
|
||||||
func InitItemProgress(itemID string) {
|
func InitItemProgress(itemID string) {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FinishItemProgress marks a download item as complete and removes tracking
|
|
||||||
func FinishItemProgress(itemID string) {
|
func FinishItemProgress(itemID string) {
|
||||||
CompleteItemProgress(itemID)
|
CompleteItemProgress(itemID)
|
||||||
// Don't remove immediately - let Flutter poll one more time to see 100%
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearItemProgress removes progress tracking for a specific item
|
|
||||||
func ClearItemProgress(itemID string) {
|
func ClearItemProgress(itemID string) {
|
||||||
RemoveItemProgress(itemID)
|
RemoveItemProgress(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CancelDownload cancels an in-progress download for the given item.
|
|
||||||
func CancelDownload(itemID string) {
|
func CancelDownload(itemID string) {
|
||||||
cancelDownload(itemID)
|
cancelDownload(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupConnections closes idle HTTP connections
|
|
||||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
|
||||||
func CleanupConnections() {
|
func CleanupConnections() {
|
||||||
CloseIdleConnections()
|
CloseIdleConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadFileMetadata reads metadata directly from a FLAC file
|
|
||||||
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
|
|
||||||
// This is useful for displaying accurate metadata in the UI without relying on cached data
|
|
||||||
func ReadFileMetadata(filePath string) (string, error) {
|
func ReadFileMetadata(filePath string) (string, error) {
|
||||||
metadata, err := ReadMetadata(filePath)
|
metadata, err := ReadMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also get audio quality info
|
|
||||||
quality, qualityErr := GetAudioQuality(filePath)
|
quality, qualityErr := GetAudioQuality(filePath)
|
||||||
|
|
||||||
// Get duration from FLAC stream info
|
|
||||||
duration := 0
|
duration := 0
|
||||||
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
@@ -589,7 +547,6 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
"duration": duration,
|
"duration": duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add quality info if available
|
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
result["bit_depth"] = quality.BitDepth
|
result["bit_depth"] = quality.BitDepth
|
||||||
result["sample_rate"] = quality.SampleRate
|
result["sample_rate"] = quality.SampleRate
|
||||||
@@ -603,12 +560,10 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadDirectory sets the default download directory
|
|
||||||
func SetDownloadDirectory(path string) error {
|
func SetDownloadDirectory(path string) error {
|
||||||
return setDownloadDir(path)
|
return setDownloadDir(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckDuplicate checks if a file with the given ISRC exists
|
|
||||||
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
||||||
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
||||||
|
|
||||||
@@ -625,27 +580,18 @@ func CheckDuplicate(outputDir, isrc string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckDuplicatesBatch checks multiple files for duplicates in parallel
|
|
||||||
// Uses ISRC index for fast lookup (builds index once, checks all tracks)
|
|
||||||
// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...]
|
|
||||||
// Returns JSON array of results
|
|
||||||
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
|
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
|
||||||
return CheckFilesExistParallel(outputDir, tracksJSON)
|
return CheckFilesExistParallel(outputDir, tracksJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreBuildDuplicateIndex pre-builds the ISRC index for a directory
|
|
||||||
// Call this when entering album/playlist screen for faster duplicate checking
|
|
||||||
func PreBuildDuplicateIndex(outputDir string) error {
|
func PreBuildDuplicateIndex(outputDir string) error {
|
||||||
return PreBuildISRCIndex(outputDir)
|
return PreBuildISRCIndex(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
|
||||||
// Call this when files are deleted or moved
|
|
||||||
func InvalidateDuplicateIndex(outputDir string) {
|
func InvalidateDuplicateIndex(outputDir string) {
|
||||||
InvalidateISRCCache(outputDir)
|
InvalidateISRCCache(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildFilename builds a filename from template and metadata
|
|
||||||
func BuildFilename(template string, metadataJSON string) (string, error) {
|
func BuildFilename(template string, metadataJSON string) (string, error) {
|
||||||
var metadata map[string]interface{}
|
var metadata map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||||
@@ -656,25 +602,24 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
|
|||||||
return filename, nil
|
return filename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SanitizeFilename removes invalid characters from filename
|
|
||||||
func SanitizeFilename(filename string) string {
|
func SanitizeFilename(filename string) string {
|
||||||
return sanitizeFilename(filename)
|
return sanitizeFilename(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchLyrics fetches lyrics for a track from LRCLIB
|
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
|
||||||
// Returns JSON with lyrics data
|
|
||||||
func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
"source": lyrics.Source,
|
"source": lyrics.Source,
|
||||||
"sync_type": lyrics.SyncType,
|
"sync_type": lyrics.SyncType,
|
||||||
"lines": lyrics.Lines,
|
"lines": lyrics.Lines,
|
||||||
|
"instrumental": lyrics.Instrumental,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
@@ -685,30 +630,34 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||||
// First tries to extract from file, then falls back to fetching from internet
|
// If filePath is provided, ONLY check file - don't fallback to online
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
// This allows Flutter to distinguish between "from file" vs "from online"
|
||||||
// Try to extract from file first (much faster)
|
|
||||||
if filePath != "" {
|
if filePath != "" {
|
||||||
lyrics, err := ExtractLyrics(filePath)
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
if err == nil && lyrics != "" {
|
if err == nil && lyrics != "" {
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
// File has no lyrics - return empty, let Flutter call again without filePath
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to fetching from internet
|
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to LRC format with metadata headers (like PC version)
|
// Return special marker for instrumental tracks
|
||||||
|
if lyricsData.Instrumental {
|
||||||
|
return "[instrumental:true]", nil
|
||||||
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedLyricsToFile embeds lyrics into an existing FLAC file
|
|
||||||
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||||
err := EmbedLyrics(filePath, lyrics)
|
err := EmbedLyrics(filePath, lyrics)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -724,9 +673,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
|
|
||||||
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
|
|
||||||
// This runs in background and returns immediately
|
|
||||||
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
||||||
var tracks []struct {
|
var tracks []struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
@@ -740,7 +686,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
|||||||
return errorResponse("Invalid JSON: " + err.Error())
|
return errorResponse("Invalid JSON: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to PreWarmCacheRequest
|
|
||||||
requests := make([]PreWarmCacheRequest, len(tracks))
|
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||||
for i, t := range tracks {
|
for i, t := range tracks {
|
||||||
requests[i] = PreWarmCacheRequest{
|
requests[i] = PreWarmCacheRequest{
|
||||||
@@ -752,7 +697,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run in background
|
|
||||||
go PreWarmTrackCache(requests)
|
go PreWarmTrackCache(requests)
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
@@ -764,26 +708,20 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrackCacheSize returns the current track ID cache size
|
|
||||||
func GetTrackCacheSize() int {
|
func GetTrackCacheSize() int {
|
||||||
return GetCacheSize()
|
return GetCacheSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearTrackIDCache clears the track ID cache
|
|
||||||
func ClearTrackIDCache() {
|
func ClearTrackIDCache() {
|
||||||
ClearTrackCache()
|
ClearTrackCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== DEEZER API ====================
|
func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||||
|
|
||||||
// SearchDeezerAll searches for tracks and artists on Deezer (no API key required)
|
|
||||||
// Returns JSON with tracks and artists arrays
|
|
||||||
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := GetDeezerClient()
|
client := GetDeezerClient()
|
||||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -852,6 +790,37 @@ func ParseDeezerURLExport(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
|
||||||
|
// trackID: Deezer track ID (will look up album ID from track)
|
||||||
|
// Returns JSON with genre, label fields
|
||||||
|
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||||
|
if trackID == "" {
|
||||||
|
return "", fmt.Errorf("empty track ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := GetDeezerClient()
|
||||||
|
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]string{
|
||||||
|
"genre": metadata.Genre,
|
||||||
|
"label": metadata.Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// SearchDeezerByISRC searches for a track by ISRC on Deezer
|
// SearchDeezerByISRC searches for a track by ISRC on Deezer
|
||||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -872,7 +841,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
||||||
// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer
|
|
||||||
// Useful when Spotify API is rate limited
|
// Useful when Spotify API is rate limited
|
||||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
@@ -881,14 +849,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
|
|
||||||
// For tracks, we can use SongLink to get Deezer ID
|
|
||||||
if resourceType == "track" {
|
if resourceType == "track" {
|
||||||
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch metadata from Deezer
|
|
||||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||||
@@ -902,14 +868,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For albums, SongLink also provides mapping
|
|
||||||
if resourceType == "album" {
|
if resourceType == "album" {
|
||||||
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch album metadata from Deezer
|
|
||||||
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||||
@@ -932,10 +896,8 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Try Spotify first
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No Spotify credentials - fall through to Deezer fallback
|
|
||||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||||
} else {
|
} else {
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
@@ -947,15 +909,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a rate limit error
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||||
// Not a rate limit error, return original error
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limited - try Deezer fallback for tracks and albums
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
||||||
@@ -964,11 +923,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
// Convert to Deezer
|
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist and playlist not supported for fallback
|
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
||||||
}
|
}
|
||||||
@@ -976,10 +933,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== SONGLINK DEEZER SUPPORT ====================
|
|
||||||
|
|
||||||
// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source
|
|
||||||
// Returns JSON with availability info for Spotify, Tidal, Amazon, etc.
|
|
||||||
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
@@ -1033,7 +986,6 @@ func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
// Determine error type based on message
|
|
||||||
errorType := "unknown"
|
errorType := "unknown"
|
||||||
lowerMsg := strings.ToLower(msg)
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
@@ -1122,7 +1074,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize with saved settings
|
|
||||||
settingsStore := GetExtensionSettingsStore()
|
settingsStore := GetExtensionSettingsStore()
|
||||||
settings := settingsStore.GetAll(ext.ID)
|
settings := settingsStore.GetAll(ext.ID)
|
||||||
if len(settings) > 0 {
|
if len(settings) > 0 {
|
||||||
@@ -1165,14 +1116,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize with saved settings
|
|
||||||
settingsStore := GetExtensionSettingsStore()
|
settingsStore := GetExtensionSettingsStore()
|
||||||
settings := settingsStore.GetAll(ext.ID)
|
settings := settingsStore.GetAll(ext.ID)
|
||||||
if len(settings) > 0 {
|
if len(settings) > 0 {
|
||||||
manager.InitializeExtension(ext.ID, settings)
|
manager.InitializeExtension(ext.ID, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return extension info as JSON
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": ext.ID,
|
"id": ext.ID,
|
||||||
"display_name": ext.Manifest.DisplayName,
|
"display_name": ext.Manifest.DisplayName,
|
||||||
@@ -1273,7 +1222,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-initialize extension with new settings
|
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
return manager.InitializeExtension(extensionID, settings)
|
return manager.InitializeExtension(extensionID, settings)
|
||||||
}
|
}
|
||||||
@@ -1320,7 +1268,22 @@ func CleanupExtensions() {
|
|||||||
manager.UnloadAllExtensions()
|
manager.UnloadAllExtensions()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION AUTH API ====================
|
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
|
||||||
|
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
|
||||||
|
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||||
|
manager := GetExtensionManager()
|
||||||
|
result, err := manager.InvokeAction(extensionID, actionName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
||||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||||
@@ -1372,7 +1335,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is expired
|
|
||||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1402,9 +1364,6 @@ func GetAllPendingAuthRequestsJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION FFMPEG API ====================
|
|
||||||
|
|
||||||
// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute
|
|
||||||
func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
|
func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
|
||||||
cmd := GetPendingFFmpegCommand(commandID)
|
cmd := GetPendingFFmpegCommand(commandID)
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
@@ -1464,12 +1423,10 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
|||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Extension not found, return original track
|
|
||||||
return trackJSON, nil
|
return trackJSON, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ext.Manifest.IsMetadataProvider() {
|
if !ext.Manifest.IsMetadataProvider() {
|
||||||
// Not a metadata provider, return original
|
|
||||||
return trackJSON, nil
|
return trackJSON, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1481,7 +1438,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
|||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
enrichedTrack, err := provider.EnrichTrack(&track)
|
enrichedTrack, err := provider.EnrichTrack(&track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Error enriching, return original
|
|
||||||
return trackJSON, nil
|
return trackJSON, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1518,7 +1474,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to map format for Flutter, ensuring images field is set
|
|
||||||
result := make([]map[string]interface{}, len(tracks))
|
result := make([]map[string]interface{}, len(tracks))
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
result[i] = map[string]interface{}{
|
result[i] = map[string]interface{}{
|
||||||
@@ -1571,10 +1526,6 @@ func GetSearchProvidersJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION URL HANDLER ====================
|
|
||||||
|
|
||||||
// HandleURLWithExtensionJSON tries to handle a URL with any matching extension
|
|
||||||
// Returns JSON with type, tracks, album info, etc.
|
|
||||||
func HandleURLWithExtensionJSON(url string) (string, error) {
|
func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
resultWithID, err := manager.HandleURLWithExtension(url)
|
resultWithID, err := manager.HandleURLWithExtension(url)
|
||||||
@@ -1585,12 +1536,10 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
result := resultWithID.Result
|
result := resultWithID.Result
|
||||||
extensionID := resultWithID.ExtensionID
|
extensionID := resultWithID.ExtensionID
|
||||||
|
|
||||||
// Check if result is nil (handler found but returned error)
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
|
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"type": result.Type,
|
"type": result.Type,
|
||||||
"extension_id": extensionID,
|
"extension_id": extensionID,
|
||||||
@@ -1598,7 +1547,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"cover_url": result.CoverURL,
|
"cover_url": result.CoverURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add track if single track
|
|
||||||
if result.Track != nil {
|
if result.Track != nil {
|
||||||
response["track"] = map[string]interface{}{
|
response["track"] = map[string]interface{}{
|
||||||
"id": result.Track.ID,
|
"id": result.Track.ID,
|
||||||
@@ -1616,7 +1564,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tracks if multiple
|
|
||||||
if len(result.Tracks) > 0 {
|
if len(result.Tracks) > 0 {
|
||||||
tracks := make([]map[string]interface{}, len(result.Tracks))
|
tracks := make([]map[string]interface{}, len(result.Tracks))
|
||||||
for i, track := range result.Tracks {
|
for i, track := range result.Tracks {
|
||||||
@@ -1654,7 +1601,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add artist info if present
|
|
||||||
if result.Artist != nil {
|
if result.Artist != nil {
|
||||||
artistResponse := map[string]interface{}{
|
artistResponse := map[string]interface{}{
|
||||||
"id": result.Artist.ID,
|
"id": result.Artist.ID,
|
||||||
@@ -1665,7 +1611,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"provider_id": result.Artist.ProviderID,
|
"provider_id": result.Artist.ProviderID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add albums if present
|
|
||||||
if len(result.Artist.Albums) > 0 {
|
if len(result.Artist.Albums) > 0 {
|
||||||
albums := make([]map[string]interface{}, len(result.Artist.Albums))
|
albums := make([]map[string]interface{}, len(result.Artist.Albums))
|
||||||
for i, album := range result.Artist.Albums {
|
for i, album := range result.Artist.Albums {
|
||||||
@@ -1688,7 +1633,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
artistResponse["albums"] = albums
|
artistResponse["albums"] = albums
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add top tracks if present
|
|
||||||
if len(result.Artist.TopTracks) > 0 {
|
if len(result.Artist.TopTracks) > 0 {
|
||||||
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
||||||
for i, track := range result.Artist.TopTracks {
|
for i, track := range result.Artist.TopTracks {
|
||||||
@@ -1758,14 +1702,17 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
return "", fmt.Errorf("album not found")
|
return "", fmt.Errorf("album not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert tracks to map format
|
|
||||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||||
for i, track := range album.Tracks {
|
for i, track := range album.Tracks {
|
||||||
// Use album cover as fallback if track doesn't have its own cover
|
|
||||||
trackCover := track.ResolvedCoverURL()
|
trackCover := track.ResolvedCoverURL()
|
||||||
if trackCover == "" {
|
if trackCover == "" {
|
||||||
trackCover = album.CoverURL
|
trackCover = album.CoverURL
|
||||||
}
|
}
|
||||||
|
// Use track number from extension, fallback to index+1 if not provided
|
||||||
|
trackNum := track.TrackNumber
|
||||||
|
if trackNum == 0 {
|
||||||
|
trackNum = i + 1
|
||||||
|
}
|
||||||
tracks[i] = map[string]interface{}{
|
tracks[i] = map[string]interface{}{
|
||||||
"id": track.ID,
|
"id": track.ID,
|
||||||
"name": track.Name,
|
"name": track.Name,
|
||||||
@@ -1775,7 +1722,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"cover_url": trackCover,
|
"cover_url": trackCover,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": trackNum,
|
||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
@@ -1788,6 +1735,7 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
|||||||
"id": album.ID,
|
"id": album.ID,
|
||||||
"name": album.Name,
|
"name": album.Name,
|
||||||
"artists": album.Artists,
|
"artists": album.Artists,
|
||||||
|
"artist_id": album.ArtistID,
|
||||||
"cover_url": album.CoverURL,
|
"cover_url": album.CoverURL,
|
||||||
"release_date": album.ReleaseDate,
|
"release_date": album.ReleaseDate,
|
||||||
"total_tracks": album.TotalTracks,
|
"total_tracks": album.TotalTracks,
|
||||||
@@ -1818,7 +1766,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
// Try getPlaylist first, fall back to getAlbum (some extensions use album for playlists)
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
|
||||||
@@ -1846,7 +1793,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse into album metadata (same structure)
|
|
||||||
var album ExtAlbumMetadata
|
var album ExtAlbumMetadata
|
||||||
if err := json.Unmarshal(jsonBytes, &album); err != nil {
|
if err := json.Unmarshal(jsonBytes, &album); err != nil {
|
||||||
return "", fmt.Errorf("failed to parse playlist: %w", err)
|
return "", fmt.Errorf("failed to parse playlist: %w", err)
|
||||||
@@ -1856,10 +1802,8 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
|||||||
album.Tracks[i].ProviderID = ext.ID
|
album.Tracks[i].ProviderID = ext.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert tracks to map format
|
|
||||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||||
for i, track := range album.Tracks {
|
for i, track := range album.Tracks {
|
||||||
// Use playlist cover as fallback if track doesn't have its own cover
|
|
||||||
trackCover := track.ResolvedCoverURL()
|
trackCover := track.ResolvedCoverURL()
|
||||||
if trackCover == "" {
|
if trackCover == "" {
|
||||||
trackCover = album.CoverURL
|
trackCover = album.CoverURL
|
||||||
@@ -1922,7 +1866,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
return "", fmt.Errorf("artist not found")
|
return "", fmt.Errorf("artist not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert albums to map format
|
|
||||||
albums := make([]map[string]interface{}, len(artist.Albums))
|
albums := make([]map[string]interface{}, len(artist.Albums))
|
||||||
for i, album := range artist.Albums {
|
for i, album := range artist.Albums {
|
||||||
albums[i] = map[string]interface{}{
|
albums[i] = map[string]interface{}{
|
||||||
@@ -1950,7 +1893,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||||||
response["header_image"] = artist.HeaderImage
|
response["header_image"] = artist.HeaderImage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add listeners if present
|
|
||||||
if artist.Listeners > 0 {
|
if artist.Listeners > 0 {
|
||||||
response["listeners"] = artist.Listeners
|
response["listeners"] = artist.Listeners
|
||||||
}
|
}
|
||||||
@@ -2008,9 +1950,6 @@ func GetURLHandlersJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION POST-PROCESSING ====================
|
|
||||||
|
|
||||||
// RunPostProcessingJSON runs post-processing hooks on a file
|
|
||||||
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
||||||
var metadata map[string]interface{}
|
var metadata map[string]interface{}
|
||||||
if metadataJSON != "" {
|
if metadataJSON != "" {
|
||||||
@@ -2066,8 +2005,6 @@ func GetPostProcessingProvidersJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION STORE ====================
|
|
||||||
|
|
||||||
// InitExtensionStoreJSON initializes the extension store with cache directory
|
// InitExtensionStoreJSON initializes the extension store with cache directory
|
||||||
func InitExtensionStoreJSON(cacheDir string) error {
|
func InitExtensionStoreJSON(cacheDir string) error {
|
||||||
InitExtensionStore(cacheDir)
|
InitExtensionStore(cacheDir)
|
||||||
@@ -2081,7 +2018,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
|||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force refresh if requested
|
|
||||||
if forceRefresh {
|
if forceRefresh {
|
||||||
store.FetchRegistry(true)
|
store.FetchRegistry(true)
|
||||||
}
|
}
|
||||||
@@ -2162,3 +2098,53 @@ func ClearStoreCacheJSON() error {
|
|||||||
store.ClearCache()
|
store.ClearCache()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
|
||||||
|
manager := GetExtensionManager()
|
||||||
|
ext, err := manager.GetExtension(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ext.Enabled {
|
||||||
|
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
|
return extension.%s();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()
|
||||||
|
`, functionName, functionName)
|
||||||
|
|
||||||
|
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||||
|
return "", fmt.Errorf("%s returned null", functionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := result.Export()
|
||||||
|
jsonBytes, err := json.Marshal(exported)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
|
||||||
|
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
||||||
|
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
|
||||||
|
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
||||||
|
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides extension management functionality
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -15,14 +14,10 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// compareVersions compares two semantic version strings
|
|
||||||
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
|
||||||
func compareVersions(v1, v2 string) int {
|
func compareVersions(v1, v2 string) int {
|
||||||
// Parse version parts
|
|
||||||
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||||
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||||
|
|
||||||
// Pad shorter version with zeros
|
|
||||||
maxLen := len(parts1)
|
maxLen := len(parts1)
|
||||||
if len(parts2) > maxLen {
|
if len(parts2) > maxLen {
|
||||||
maxLen = len(parts2)
|
maxLen = len(parts2)
|
||||||
@@ -48,16 +43,16 @@ func compareVersions(v1, v2 string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadedExtension represents an extension that has been loaded into memory
|
|
||||||
type LoadedExtension struct {
|
type LoadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized)
|
VM *goja.Runtime `json:"-"`
|
||||||
|
VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
DataDir string `json:"data_dir"` // Extension's data directory
|
DataDir string `json:"data_dir"`
|
||||||
SourceDir string `json:"source_dir"` // Where extension files are extracted
|
SourceDir string `json:"source_dir"`
|
||||||
IconPath string `json:"icon_path"` // Full path to icon file (if exists)
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionManager manages all loaded extensions
|
// ExtensionManager manages all loaded extensions
|
||||||
@@ -68,13 +63,11 @@ type ExtensionManager struct {
|
|||||||
dataDir string // Base directory for extension data
|
dataDir string // Base directory for extension data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global extension manager instance
|
|
||||||
var (
|
var (
|
||||||
globalExtManager *ExtensionManager
|
globalExtManager *ExtensionManager
|
||||||
globalExtManagerOnce sync.Once
|
globalExtManagerOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetExtensionManager returns the global extension manager instance
|
|
||||||
func GetExtensionManager() *ExtensionManager {
|
func GetExtensionManager() *ExtensionManager {
|
||||||
globalExtManagerOnce.Do(func() {
|
globalExtManagerOnce.Do(func() {
|
||||||
globalExtManager = &ExtensionManager{
|
globalExtManager = &ExtensionManager{
|
||||||
@@ -84,7 +77,6 @@ func GetExtensionManager() *ExtensionManager {
|
|||||||
return globalExtManager
|
return globalExtManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDirectories sets the extensions and data directories
|
|
||||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -92,7 +84,6 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
m.extensionsDir = extensionsDir
|
m.extensionsDir = extensionsDir
|
||||||
m.dataDir = dataDir
|
m.dataDir = dataDir
|
||||||
|
|
||||||
// Create directories if they don't exist
|
|
||||||
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
|
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create extensions directory: %w", err)
|
return fmt.Errorf("failed to create extensions directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -103,9 +94,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadExtensionFromFile loads an extension from a .spotiflac-ext file
|
|
||||||
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
||||||
// Validate file extension
|
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
@@ -117,7 +106,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
// Find and read manifest.json
|
|
||||||
var manifestData []byte
|
var manifestData []byte
|
||||||
var hasIndexJS bool
|
var hasIndexJS bool
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
@@ -146,13 +134,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension already loaded - if so, try upgrade (check without holding lock for long)
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existing, exists := m.extensions[manifest.Name]
|
existing, exists := m.extensions[manifest.Name]
|
||||||
var existingVersion string
|
var existingVersion string
|
||||||
@@ -164,7 +150,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
// Check if this is an upgrade
|
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
// This is an upgrade - call UpgradeExtension
|
// This is an upgrade - call UpgradeExtension
|
||||||
@@ -176,29 +161,23 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now acquire write lock for the rest of the operation
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// Double-check extension wasn't added while we were waiting for lock
|
|
||||||
if _, exists := m.extensions[manifest.Name]; exists {
|
if _, exists := m.extensions[manifest.Name]; exists {
|
||||||
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extension directory
|
|
||||||
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract all files (preserving directory structure)
|
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve relative path within the zip (support subdirectories)
|
|
||||||
// Clean the path to prevent path traversal attacks
|
|
||||||
relPath := filepath.Clean(file.Name)
|
relPath := filepath.Clean(file.Name)
|
||||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||||
@@ -206,19 +185,16 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
destPath := filepath.Join(extDir, relPath)
|
destPath := filepath.Join(extDir, relPath)
|
||||||
|
|
||||||
// Create parent directories if needed
|
|
||||||
destDir := filepath.Dir(destPath)
|
destDir := filepath.Dir(destPath)
|
||||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination file
|
|
||||||
destFile, err := os.Create(destPath)
|
destFile, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy content
|
|
||||||
srcFile, err := file.Open()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
destFile.Close()
|
destFile.Close()
|
||||||
@@ -233,13 +209,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directory for extension
|
|
||||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create loaded extension
|
|
||||||
ext := &LoadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
@@ -261,25 +235,20 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initializeVM creates and initializes the Goja VM for an extension
|
|
||||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||||
// Create new Goja runtime
|
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
|
|
||||||
// Read index.js
|
|
||||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||||
jsCode, err := os.ReadFile(indexPath)
|
jsCode, err := os.ReadFile(indexPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extension runtime and register sandboxed APIs
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
|
|
||||||
// Set up console.log for debugging
|
|
||||||
console := vm.NewObject()
|
console := vm.NewObject()
|
||||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||||
args := make([]interface{}, len(call.Arguments))
|
args := make([]interface{}, len(call.Arguments))
|
||||||
@@ -291,12 +260,10 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
})
|
})
|
||||||
vm.Set("console", console)
|
vm.Set("console", console)
|
||||||
|
|
||||||
// Set up registerExtension function
|
|
||||||
var registeredExtension goja.Value
|
var registeredExtension goja.Value
|
||||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) > 0 {
|
if len(call.Arguments) > 0 {
|
||||||
registeredExtension = call.Arguments[0]
|
registeredExtension = call.Arguments[0]
|
||||||
// Also set it as global 'extension' variable for later access
|
|
||||||
vm.Set("extension", call.Arguments[0])
|
vm.Set("extension", call.Arguments[0])
|
||||||
}
|
}
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
@@ -344,7 +311,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExtension returns a loaded extension by ID
|
|
||||||
// Returns error if extension not found (gomobile compatible)
|
// 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()
|
||||||
@@ -369,7 +335,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExtensionEnabled enables or disables an extension
|
|
||||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -406,7 +371,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
// Check if it's an extracted extension directory
|
|
||||||
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
|
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
|
||||||
if _, err := os.Stat(manifestPath); err == nil {
|
if _, err := os.Stat(manifestPath); err == nil {
|
||||||
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
|
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
|
||||||
@@ -418,7 +382,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||||
// Load from package file
|
|
||||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||||
@@ -432,12 +395,10 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
return loaded, errors
|
return loaded, errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadExtensionFromDirectory loads an extension from an already extracted directory
|
|
||||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// Read manifest
|
|
||||||
manifestPath := filepath.Join(dirPath, "manifest.json")
|
manifestPath := filepath.Join(dirPath, "manifest.json")
|
||||||
manifestData, err := os.ReadFile(manifestPath)
|
manifestData, err := os.ReadFile(manifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -450,25 +411,21 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if index.js exists
|
|
||||||
indexPath := filepath.Join(dirPath, "index.js")
|
indexPath := filepath.Join(dirPath, "index.js")
|
||||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("Extension is missing index.js file")
|
return nil, fmt.Errorf("Extension is missing index.js file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension already loaded - skip silently (for directory loading on startup)
|
|
||||||
if existing, exists := m.extensions[manifest.Name]; exists {
|
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||||
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directory for extension
|
|
||||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create loaded extension
|
|
||||||
ext := &LoadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
@@ -526,7 +483,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpgradeExtension upgrades an existing extension from a new package file
|
|
||||||
// Only allows upgrades (new version > current version), not downgrades
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||||
// Validate file extension
|
// Validate file extension
|
||||||
@@ -541,7 +497,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
// Find and read manifest.json
|
|
||||||
var manifestData []byte
|
var manifestData []byte
|
||||||
var hasIndexJS bool
|
var hasIndexJS bool
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
@@ -570,13 +525,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension exists
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existing, exists := m.extensions[newManifest.Name]
|
existing, exists := m.extensions[newManifest.Name]
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
@@ -612,19 +565,15 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate extension directory
|
|
||||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract all files from new package (preserving directory structure)
|
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve relative path within the zip (support subdirectories)
|
|
||||||
// Clean the path to prevent path traversal attacks
|
|
||||||
relPath := filepath.Clean(file.Name)
|
relPath := filepath.Clean(file.Name)
|
||||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||||
@@ -632,19 +581,16 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
destPath := filepath.Join(extDir, relPath)
|
destPath := filepath.Join(extDir, relPath)
|
||||||
|
|
||||||
// Create parent directories if needed
|
|
||||||
destDir := filepath.Dir(destPath)
|
destDir := filepath.Dir(destPath)
|
||||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination file
|
|
||||||
destFile, err := os.Create(destPath)
|
destFile, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy content
|
|
||||||
srcFile, err := file.Open()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
destFile.Close()
|
destFile.Close()
|
||||||
@@ -659,7 +605,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new loaded extension (reusing data directory, preserving enabled state)
|
|
||||||
ext := &LoadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: newManifest.Name,
|
ID: newManifest.Name,
|
||||||
Manifest: newManifest,
|
Manifest: newManifest,
|
||||||
@@ -684,7 +629,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionUpgradeInfo holds information about extension upgrade check
|
|
||||||
type ExtensionUpgradeInfo struct {
|
type ExtensionUpgradeInfo struct {
|
||||||
ExtensionID string `json:"extension_id"`
|
ExtensionID string `json:"extension_id"`
|
||||||
CurrentVersion string `json:"current_version"`
|
CurrentVersion string `json:"current_version"`
|
||||||
@@ -708,7 +652,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
// Find and read manifest.json
|
|
||||||
var manifestData []byte
|
var manifestData []byte
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
name := filepath.Base(file.Name)
|
name := filepath.Base(file.Name)
|
||||||
@@ -730,13 +673,11 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
return nil, fmt.Errorf("manifest.json not found")
|
return nil, fmt.Errorf("manifest.json not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse manifest
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension exists
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existing, exists := m.extensions[newManifest.Name]
|
existing, exists := m.extensions[newManifest.Name]
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
@@ -752,7 +693,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
info.CurrentVersion = ""
|
info.CurrentVersion = ""
|
||||||
info.CanUpgrade = false
|
info.CanUpgrade = false
|
||||||
} else {
|
} else {
|
||||||
// Compare versions
|
|
||||||
info.CurrentVersion = existing.Manifest.Version
|
info.CurrentVersion = existing.Manifest.Version
|
||||||
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
|
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
|
||||||
}
|
}
|
||||||
@@ -760,7 +700,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON
|
|
||||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -780,32 +719,32 @@ 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))
|
||||||
for i, ext := range extensions {
|
for i, ext := range extensions {
|
||||||
// Build permissions list
|
|
||||||
permissions := []string{}
|
permissions := []string{}
|
||||||
for _, domain := range ext.Manifest.Permissions.Network {
|
for _, domain := range ext.Manifest.Permissions.Network {
|
||||||
permissions = append(permissions, "network:"+domain)
|
permissions = append(permissions, "network:"+domain)
|
||||||
@@ -822,7 +761,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
status = "disabled"
|
status = "disabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for icon file
|
|
||||||
iconPath := ""
|
iconPath := ""
|
||||||
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
||||||
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
||||||
@@ -830,7 +768,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
iconPath = possibleIcon
|
iconPath = possibleIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: check for icon.png if not specified in manifest
|
|
||||||
if iconPath == "" && ext.SourceDir != "" {
|
if iconPath == "" && ext.SourceDir != "" {
|
||||||
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
||||||
if _, err := os.Stat(possibleIcon); err == nil {
|
if _, err := os.Stat(possibleIcon); err == nil {
|
||||||
@@ -860,6 +797,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,7 +811,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
|
|
||||||
// ==================== Extension Lifecycle ====================
|
// ==================== Extension Lifecycle ====================
|
||||||
|
|
||||||
// InitializeExtension calls the extension's initialize method with settings
|
|
||||||
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()
|
||||||
@@ -887,13 +824,11 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert settings to JSON for passing to JS
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
settingsJSON, err := json.Marshal(settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to save settings")
|
return fmt.Errorf("Failed to save settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call initialize function
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
var settings = %s;
|
var settings = %s;
|
||||||
@@ -917,7 +852,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check result
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
exported := result.Export()
|
exported := result.Export()
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
@@ -938,7 +872,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupExtension calls the extension's cleanup method
|
|
||||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -949,10 +882,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
return nil // No VM, nothing to cleanup
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call cleanup function
|
|
||||||
script := `
|
script := `
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||||
@@ -973,7 +905,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check result
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
exported := result.Export()
|
exported := result.Export()
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
@@ -1002,11 +933,65 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
for _, id := range extensionIDs {
|
for _, id := range extensionIDs {
|
||||||
// Call cleanup first
|
|
||||||
m.CleanupExtension(id)
|
m.CleanupExtension(id)
|
||||||
// Then unload
|
|
||||||
m.UnloadExtension(id)
|
m.UnloadExtension(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
ext, exists := m.extensions[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext.VM == nil {
|
||||||
|
return nil, fmt.Errorf("extension VM not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ext.Enabled {
|
||||||
|
return nil, fmt.Errorf("extension is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the action function on the extension object
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
|
try {
|
||||||
|
var result = extension.%s();
|
||||||
|
if (result && typeof result.then === 'function') {
|
||||||
|
// Handle promise - return pending status
|
||||||
|
return { success: true, pending: true, message: 'Action started' };
|
||||||
|
}
|
||||||
|
return { success: true, result: result };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Action function not found: %s' };
|
||||||
|
})()
|
||||||
|
`, actionName, actionName, actionName)
|
||||||
|
|
||||||
|
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||||
|
return nil, fmt.Errorf("action failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil || goja.IsUndefined(result) {
|
||||||
|
return map[string]interface{}{"success": true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
|
||||||
|
return resultMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{"success": true, "result": exported}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
SettingTypeNumber SettingType = "number"
|
SettingTypeNumber SettingType = "number"
|
||||||
SettingTypeBool SettingType = "boolean"
|
SettingTypeBool SettingType = "boolean"
|
||||||
SettingTypeSelect SettingType = "select"
|
SettingTypeSelect SettingType = "select"
|
||||||
|
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionPermissions defines what resources an extension can access
|
// ExtensionPermissions defines what resources an extension can access
|
||||||
@@ -42,6 +43,7 @@ type ExtensionSetting struct {
|
|||||||
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"` // For select type
|
||||||
|
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
|
||||||
}
|
}
|
||||||
|
|
||||||
// QualityOption represents a quality option for download providers
|
// QualityOption represents a quality option for download providers
|
||||||
@@ -64,15 +66,23 @@ type QualitySpecificSetting struct {
|
|||||||
Options []string `json:"options,omitempty"` // For select type
|
Options []string `json:"options,omitempty"` // For select type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchFilter defines a filter option for search
|
||||||
|
type SearchFilter struct {
|
||||||
|
ID string `json:"id"` // Filter identifier (e.g., "track", "album", "artist", "playlist")
|
||||||
|
Label string `json:"label,omitempty"` // Display label (e.g., "Songs", "Albums", "Artists", "Playlists")
|
||||||
|
Icon string `json:"icon,omitempty"` // Optional icon name
|
||||||
|
}
|
||||||
|
|
||||||
// SearchBehaviorConfig defines custom search behavior for an extension
|
// 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"` // Whether extension provides custom search
|
||||||
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
|
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
|
||||||
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
|
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
|
||||||
Icon string `json:"icon,omitempty"` // Icon for search tab
|
Icon string `json:"icon,omitempty"` // Icon for search tab
|
||||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
|
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
|
||||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
||||||
|
Filters []SearchFilter `json:"filters,omitempty"` // Available search filters (e.g., track, album, artist, playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// URLHandlerConfig defines custom URL handling for an extension
|
// URLHandlerConfig defines custom URL handling for an extension
|
||||||
@@ -105,24 +115,25 @@ type PostProcessingConfig struct {
|
|||||||
|
|
||||||
// ExtensionManifest represents the manifest.json of an extension
|
// 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"` // Icon filename (e.g., "icon.png")
|
||||||
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"` // Custom quality options for download providers
|
||||||
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"` // If true, don't enrich metadata from Deezer/Spotify
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManifestValidationError represents a validation error in the manifest
|
// ManifestValidationError represents a validation error in the manifest
|
||||||
@@ -149,9 +160,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
|||||||
return &manifest, nil
|
return &manifest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the manifest has all required fields and valid values
|
|
||||||
func (m *ExtensionManifest) Validate() error {
|
func (m *ExtensionManifest) Validate() error {
|
||||||
// Check required fields
|
|
||||||
if strings.TrimSpace(m.Name) == "" {
|
if strings.TrimSpace(m.Name) == "" {
|
||||||
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||||
}
|
}
|
||||||
@@ -172,7 +181,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate extension types
|
|
||||||
for _, t := range m.Types {
|
for _, t := range m.Types {
|
||||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
@@ -198,20 +206,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate setting type
|
|
||||||
validTypes := map[SettingType]bool{
|
|
||||||
SettingTypeString: true,
|
|
||||||
SettingTypeNumber: true,
|
|
||||||
SettingTypeBool: true,
|
|
||||||
SettingTypeSelect: true,
|
|
||||||
}
|
|
||||||
if !validTypes[setting.Type] {
|
|
||||||
return &ManifestValidationError{
|
|
||||||
Field: fmt.Sprintf("settings[%d].type", i),
|
|
||||||
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select type requires options
|
// Select type requires options
|
||||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
@@ -219,6 +213,13 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
Message: "select type requires options",
|
Message: "select type requires options",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if setting.Type == SettingTypeButton && setting.Action == "" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].action", i),
|
||||||
|
Message: "button type requires action (JS function name)",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -289,7 +290,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse URL to get host
|
|
||||||
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))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -38,6 +39,10 @@ type ExtTrackMetadata struct {
|
|||||||
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"` // service -> URL mapping
|
||||||
|
// Extended metadata from enrichment (can come from Deezer, Spotify, etc.)
|
||||||
|
Label string `json:"label,omitempty"` // Record label
|
||||||
|
Copyright string `json:"copyright,omitempty"` // Copyright information
|
||||||
|
Genre string `json:"genre,omitempty"` // Music genre(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
|
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
|
||||||
@@ -53,6 +58,7 @@ 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"`
|
||||||
@@ -144,6 +150,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Call extension's searchTracks function
|
// Call extension's searchTracks function
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
@@ -189,7 +199,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set provider ID on all tracks
|
|
||||||
for i := range searchResult.Tracks {
|
for i := range searchResult.Tracks {
|
||||||
searchResult.Tracks[i].ProviderID = p.extension.ID
|
searchResult.Tracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -207,6 +216,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') {
|
||||||
@@ -253,6 +266,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
|
||||||
@@ -302,6 +319,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') {
|
||||||
@@ -350,6 +371,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
return track, nil // Extension disabled, return as-is
|
return track, nil // Extension disabled, return as-is
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lock VM to prevent concurrent access
|
||||||
|
p.extension.VMMu.Lock()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Convert track to JSON for passing to JS
|
// Convert track to JSON for passing to JS
|
||||||
trackJSON, err := json.Marshal(track)
|
trackJSON, err := json.Marshal(track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -416,6 +441,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') {
|
||||||
@@ -461,6 +490,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') {
|
||||||
@@ -509,6 +542,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Set up progress callback in VM
|
// 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 {
|
||||||
@@ -737,12 +774,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
||||||
if err == nil && enrichedTrack != nil {
|
if err == nil && enrichedTrack != nil {
|
||||||
// Update request with enriched data
|
|
||||||
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
|
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
|
||||||
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
||||||
req.ISRC = enrichedTrack.ISRC
|
req.ISRC = enrichedTrack.ISRC
|
||||||
}
|
}
|
||||||
// Update service-specific IDs from Odesli enrichment
|
|
||||||
if enrichedTrack.TidalID != "" {
|
if enrichedTrack.TidalID != "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
|
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
|
||||||
req.TidalID = enrichedTrack.TidalID
|
req.TidalID = enrichedTrack.TidalID
|
||||||
@@ -755,13 +790,29 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
|
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
|
||||||
req.DeezerID = enrichedTrack.DeezerID
|
req.DeezerID = enrichedTrack.DeezerID
|
||||||
}
|
}
|
||||||
// Can also update other fields if needed
|
|
||||||
if enrichedTrack.Name != "" {
|
if enrichedTrack.Name != "" {
|
||||||
req.TrackName = enrichedTrack.Name
|
req.TrackName = enrichedTrack.Name
|
||||||
}
|
}
|
||||||
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 == "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||||
|
req.Label = enrichedTrack.Label
|
||||||
|
}
|
||||||
|
if enrichedTrack.Copyright != "" && req.Copyright == "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright)
|
||||||
|
req.Copyright = enrichedTrack.Copyright
|
||||||
|
}
|
||||||
|
if enrichedTrack.Genre != "" && req.Genre == "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre)
|
||||||
|
req.Genre = enrichedTrack.Genre
|
||||||
|
}
|
||||||
|
if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate)
|
||||||
|
req.ReleaseDate = enrichedTrack.ReleaseDate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -772,7 +823,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
ext, err := extManager.GetExtension(req.Source)
|
ext, err := extManager.GetExtension(req.Source)
|
||||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||||
// Check if this extension wants to skip built-in fallback
|
|
||||||
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
@@ -783,7 +833,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
// Build output path
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPath(req)
|
||||||
|
|
||||||
// Download directly using the track ID from the extension
|
// Download directly using the track ID from the extension
|
||||||
@@ -801,6 +850,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: req.Source,
|
Service: req.Source,
|
||||||
|
Genre: req.Genre,
|
||||||
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed genre and label if provided (from Deezer metadata)
|
||||||
|
if req.Genre != "" || req.Label != "" {
|
||||||
|
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If extension has skipMetadataEnrichment, copy metadata
|
// If extension has skipMetadataEnrichment, copy metadata
|
||||||
@@ -884,10 +945,44 @@ 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 != "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
deezerClient := GetDeezerClient()
|
||||||
|
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||||
|
cancel()
|
||||||
|
if err == nil && extMeta != nil {
|
||||||
|
if req.Genre == "" && extMeta.Genre != "" {
|
||||||
|
req.Genre = extMeta.Genre
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Genre from Deezer: %s\n", req.Genre)
|
||||||
|
}
|
||||||
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
|
req.Label = extMeta.Label
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use built-in provider
|
// 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 != "" {
|
||||||
|
result.Label = req.Label
|
||||||
|
}
|
||||||
|
if req.Copyright != "" {
|
||||||
|
result.Copyright = req.Copyright
|
||||||
|
}
|
||||||
|
if req.Genre != "" {
|
||||||
|
result.Genre = req.Genre
|
||||||
|
}
|
||||||
|
if req.ReleaseDate != "" && result.ReleaseDate == "" {
|
||||||
|
result.ReleaseDate = req.ReleaseDate
|
||||||
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -916,7 +1011,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
// Check availability first
|
|
||||||
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
|
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
|
||||||
if err != nil || !availability.Available {
|
if err != nil || !availability.Available {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||||
@@ -926,12 +1020,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build output path
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPath(req)
|
||||||
|
|
||||||
// Download
|
|
||||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
||||||
// Update progress
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||||
}
|
}
|
||||||
@@ -945,6 +1036,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: providerID,
|
Service: providerID,
|
||||||
|
Genre: req.Genre,
|
||||||
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed genre and label if provided (from Deezer metadata)
|
||||||
|
if req.Genre != "" || req.Label != "" {
|
||||||
|
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If extension has skipMetadataEnrichment and returned metadata, use it
|
// If extension has skipMetadataEnrichment and returned metadata, use it
|
||||||
@@ -1095,6 +1198,9 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
TrackNumber: result.TrackNumber,
|
TrackNumber: result.TrackNumber,
|
||||||
DiscNumber: result.DiscNumber,
|
DiscNumber: result.DiscNumber,
|
||||||
ISRC: result.ISRC,
|
ISRC: result.ISRC,
|
||||||
|
Genre: req.Genre,
|
||||||
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1130,6 +1236,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Convert options to JSON
|
// Convert options to JSON
|
||||||
optionsJSON, _ := json.Marshal(options)
|
optionsJSON, _ := json.Marshal(options)
|
||||||
|
|
||||||
@@ -1171,7 +1281,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
tracks = []ExtTrackMetadata{}
|
tracks = []ExtTrackMetadata{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set provider ID on all tracks
|
|
||||||
for i := range tracks {
|
for i := range tracks {
|
||||||
tracks[i].ProviderID = p.extension.ID
|
tracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -1202,6 +1311,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') {
|
||||||
@@ -1255,7 +1368,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Set provider ID on top tracks
|
|
||||||
for i := range handleResult.Artist.TopTracks {
|
for i := range handleResult.Artist.TopTracks {
|
||||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -1284,6 +1396,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||||
candidatesJSON, _ := json.Marshal(candidates)
|
candidatesJSON, _ := json.Marshal(candidates)
|
||||||
|
|
||||||
@@ -1347,6 +1463,10 @@ 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()
|
||||||
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
metadataJSON, _ := json.Marshal(metadata)
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -1493,12 +1613,10 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
|||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
// Check if hook is enabled (TODO: check user settings)
|
|
||||||
if !hook.DefaultEnabled {
|
if !hook.DefaultEnabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if format is supported
|
|
||||||
ext := strings.ToLower(filepath.Ext(currentPath))
|
ext := strings.ToLower(filepath.Ext(currentPath))
|
||||||
if len(hook.SupportedFormats) > 0 {
|
if len(hook.SupportedFormats) > 0 {
|
||||||
supported := false
|
supported := false
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// Package gobackend provides extension runtime with sandboxed execution
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,16 +9,13 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default timeout for JS execution (30 seconds)
|
|
||||||
const DefaultJSTimeout = 30 * time.Second
|
const DefaultJSTimeout = 30 * time.Second
|
||||||
|
|
||||||
// Global auth state for extensions (stores pending auth codes)
|
|
||||||
var (
|
var (
|
||||||
extensionAuthState = make(map[string]*ExtensionAuthState)
|
extensionAuthState = make(map[string]*ExtensionAuthState)
|
||||||
extensionAuthStateMu sync.RWMutex
|
extensionAuthStateMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionAuthState holds auth state for an extension
|
|
||||||
type ExtensionAuthState struct {
|
type ExtensionAuthState struct {
|
||||||
PendingAuthURL string
|
PendingAuthURL string
|
||||||
AuthCode string
|
AuthCode string
|
||||||
@@ -32,14 +28,12 @@ type ExtensionAuthState struct {
|
|||||||
PKCEChallenge string
|
PKCEChallenge string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
|
|
||||||
type PendingAuthRequest struct {
|
type PendingAuthRequest struct {
|
||||||
ExtensionID string
|
ExtensionID string
|
||||||
AuthURL string
|
AuthURL string
|
||||||
CallbackURL string
|
CallbackURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global pending auth requests (Flutter polls this)
|
|
||||||
var (
|
var (
|
||||||
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
||||||
pendingAuthRequestsMu sync.RWMutex
|
pendingAuthRequestsMu sync.RWMutex
|
||||||
@@ -52,14 +46,12 @@ func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
|||||||
return pendingAuthRequests[extensionID]
|
return pendingAuthRequests[extensionID]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
|
|
||||||
func ClearPendingAuthRequest(extensionID string) {
|
func ClearPendingAuthRequest(extensionID string) {
|
||||||
pendingAuthRequestsMu.Lock()
|
pendingAuthRequestsMu.Lock()
|
||||||
defer pendingAuthRequestsMu.Unlock()
|
defer pendingAuthRequestsMu.Unlock()
|
||||||
delete(pendingAuthRequests, extensionID)
|
delete(pendingAuthRequests, extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
|
|
||||||
func SetExtensionAuthCode(extensionID string, authCode string) {
|
func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
defer extensionAuthStateMu.Unlock()
|
defer extensionAuthStateMu.Unlock()
|
||||||
@@ -72,7 +64,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) {
|
|||||||
state.AuthCode = authCode
|
state.AuthCode = authCode
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetExtensionTokens sets access/refresh tokens for an extension
|
|
||||||
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
defer extensionAuthStateMu.Unlock()
|
defer extensionAuthStateMu.Unlock()
|
||||||
@@ -88,7 +79,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
|||||||
state.IsAuthenticated = accessToken != ""
|
state.IsAuthenticated = accessToken != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionRuntime provides sandboxed APIs for extensions
|
|
||||||
type ExtensionRuntime struct {
|
type ExtensionRuntime struct {
|
||||||
extensionID string
|
extensionID string
|
||||||
manifest *ExtensionManifest
|
manifest *ExtensionManifest
|
||||||
@@ -99,9 +89,7 @@ type ExtensionRuntime struct {
|
|||||||
vm *goja.Runtime
|
vm *goja.Runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExtensionRuntime creates a new runtime for an extension
|
|
||||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||||
// Create a cookie jar for this extension
|
|
||||||
jar, _ := newSimpleCookieJar()
|
jar, _ := newSimpleCookieJar()
|
||||||
|
|
||||||
runtime := &ExtensionRuntime{
|
runtime := &ExtensionRuntime{
|
||||||
@@ -113,7 +101,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
vm: ext.VM,
|
vm: ext.VM,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create HTTP client with redirect validation to prevent SSRF via open redirect
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Jar: jar,
|
Jar: jar,
|
||||||
@@ -124,7 +111,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
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}
|
||||||
}
|
}
|
||||||
// Also block redirects to private/local networks (SSRF protection)
|
|
||||||
if isPrivateIP(domain) {
|
if isPrivateIP(domain) {
|
||||||
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}
|
||||||
@@ -141,7 +127,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
return runtime
|
return runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
|
|
||||||
type RedirectBlockedError struct {
|
type RedirectBlockedError struct {
|
||||||
Domain string
|
Domain string
|
||||||
IsPrivate bool
|
IsPrivate bool
|
||||||
@@ -167,10 +152,10 @@ func isPrivateIP(host string) bool {
|
|||||||
"172.24.", "172.25.", "172.26.", "172.27.",
|
"172.24.", "172.25.", "172.26.", "172.27.",
|
||||||
"172.28.", "172.29.", "172.30.", "172.31.",
|
"172.28.", "172.29.", "172.30.", "172.31.",
|
||||||
"192.168.",
|
"192.168.",
|
||||||
"169.254.", // Link-local
|
"169.254.",
|
||||||
"::1", // IPv6 localhost
|
"::1",
|
||||||
"fc00:", // IPv6 private
|
"fc00:",
|
||||||
"fe80:", // IPv6 link-local
|
"fe80:",
|
||||||
}
|
}
|
||||||
|
|
||||||
hostLower := host
|
hostLower := host
|
||||||
@@ -188,7 +173,6 @@ func isPrivateIP(host string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// simpleCookieJar is a simple in-memory cookie jar
|
|
||||||
type simpleCookieJar struct {
|
type simpleCookieJar struct {
|
||||||
cookies map[string][]*http.Cookie
|
cookies map[string][]*http.Cookie
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -213,7 +197,6 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
|||||||
return j.cookies[u.Host]
|
return j.cookies[u.Host]
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSettings updates the runtime settings
|
|
||||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||||
r.settings = settings
|
r.settings = settings
|
||||||
}
|
}
|
||||||
@@ -233,7 +216,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||||
vm.Set("http", httpObj)
|
vm.Set("http", httpObj)
|
||||||
|
|
||||||
// Storage API
|
|
||||||
storageObj := vm.NewObject()
|
storageObj := vm.NewObject()
|
||||||
storageObj.Set("get", r.storageGet)
|
storageObj.Set("get", r.storageGet)
|
||||||
storageObj.Set("set", r.storageSet)
|
storageObj.Set("set", r.storageSet)
|
||||||
@@ -248,7 +230,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
credentialsObj.Set("has", r.credentialsHas)
|
credentialsObj.Set("has", r.credentialsHas)
|
||||||
vm.Set("credentials", credentialsObj)
|
vm.Set("credentials", credentialsObj)
|
||||||
|
|
||||||
// Auth API (for OAuth and other auth flows)
|
|
||||||
authObj := vm.NewObject()
|
authObj := vm.NewObject()
|
||||||
authObj.Set("openAuthUrl", r.authOpenUrl)
|
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||||
authObj.Set("getAuthCode", r.authGetCode)
|
authObj.Set("getAuthCode", r.authGetCode)
|
||||||
@@ -275,7 +256,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
fileObj.Set("getSize", r.fileGetSize)
|
fileObj.Set("getSize", r.fileGetSize)
|
||||||
vm.Set("file", fileObj)
|
vm.Set("file", fileObj)
|
||||||
|
|
||||||
// FFmpeg API (for post-processing)
|
|
||||||
ffmpegObj := vm.NewObject()
|
ffmpegObj := vm.NewObject()
|
||||||
ffmpegObj.Set("execute", r.ffmpegExecute)
|
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||||
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||||
@@ -289,7 +269,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
||||||
vm.Set("matching", matchingObj)
|
vm.Set("matching", matchingObj)
|
||||||
|
|
||||||
// Utilities
|
|
||||||
utilsObj := vm.NewObject()
|
utilsObj := vm.NewObject()
|
||||||
utilsObj.Set("base64Encode", r.base64Encode)
|
utilsObj.Set("base64Encode", r.base64Encode)
|
||||||
utilsObj.Set("base64Decode", r.base64Decode)
|
utilsObj.Set("base64Decode", r.base64Decode)
|
||||||
@@ -304,6 +283,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
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)
|
||||||
vm.Set("utils", utilsObj)
|
vm.Set("utils", utilsObj)
|
||||||
|
|
||||||
// Log object (already set in extension_manager.go, but we can enhance it)
|
// Log object (already set in extension_manager.go, but we can enhance it)
|
||||||
@@ -314,7 +294,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
logObj.Set("error", r.logError)
|
logObj.Set("error", r.logError)
|
||||||
vm.Set("log", logObj)
|
vm.Set("log", logObj)
|
||||||
|
|
||||||
// Go backend functions
|
|
||||||
gobackendObj := vm.NewObject()
|
gobackendObj := vm.NewObject()
|
||||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||||
vm.Set("gobackend", gobackendObj)
|
vm.Set("gobackend", gobackendObj)
|
||||||
@@ -325,16 +304,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
||||||
vm.Set("fetch", r.fetchPolyfill)
|
vm.Set("fetch", r.fetchPolyfill)
|
||||||
|
|
||||||
// Global atob/btoa - Base64 encoding (browser-compatible)
|
|
||||||
vm.Set("atob", r.atobPolyfill)
|
vm.Set("atob", r.atobPolyfill)
|
||||||
vm.Set("btoa", r.btoaPolyfill)
|
vm.Set("btoa", r.btoaPolyfill)
|
||||||
|
|
||||||
// TextEncoder/TextDecoder constructors
|
|
||||||
r.registerTextEncoderDecoder(vm)
|
r.registerTextEncoderDecoder(vm)
|
||||||
|
|
||||||
// URL class for URL parsing
|
|
||||||
r.registerURLClass(vm)
|
r.registerURLClass(vm)
|
||||||
|
|
||||||
// JSON global (browser-compatible)
|
|
||||||
r.registerJSONGlobal(vm)
|
r.registerJSONGlobal(vm)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
|
|
||||||
// ==================== Auth API (OAuth Support) ====================
|
// ==================== Auth API (OAuth Support) ====================
|
||||||
|
|
||||||
// authOpenUrl requests Flutter to open an OAuth URL
|
|
||||||
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{}{
|
||||||
@@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
callbackURL = call.Arguments[1].String()
|
callbackURL = call.Arguments[1].String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store pending auth request for Flutter to pick up
|
|
||||||
pendingAuthRequestsMu.Lock()
|
pendingAuthRequestsMu.Lock()
|
||||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
ExtensionID: r.extensionID,
|
ExtensionID: r.extensionID,
|
||||||
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
pendingAuthRequestsMu.Unlock()
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
|
||||||
// Update auth state
|
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
state, exists := extensionAuthState[r.extensionID]
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
extensionAuthState[r.extensionID] = state
|
extensionAuthState[r.extensionID] = state
|
||||||
}
|
}
|
||||||
state.PendingAuthURL = authURL
|
state.PendingAuthURL = authURL
|
||||||
state.AuthCode = "" // Clear any previous auth code
|
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, authURL)
|
||||||
@@ -61,7 +58,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// authGetCode gets the auth code (set by Flutter after OAuth callback)
|
|
||||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
@@ -114,7 +110,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authClear clears all auth state for the extension
|
|
||||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
delete(extensionAuthState, r.extensionID)
|
delete(extensionAuthState, r.extensionID)
|
||||||
@@ -138,7 +133,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
|||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is expired
|
|
||||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -146,7 +140,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
|||||||
return r.vm.ToValue(state.IsAuthenticated)
|
return r.vm.ToValue(state.IsAuthenticated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authGetTokens returns current tokens (for extension to use in API calls)
|
|
||||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
@@ -182,16 +175,13 @@ func generatePKCEVerifier(length int) (string, error) {
|
|||||||
length = 128
|
length = 128
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate random bytes
|
|
||||||
bytes := make([]byte, length)
|
bytes := make([]byte, length)
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use base64url encoding without padding (RFC 7636 compliant)
|
|
||||||
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
|
||||||
// Trim to exact length
|
|
||||||
if len(verifier) > length {
|
if len(verifier) > length {
|
||||||
verifier = verifier[:length]
|
verifier = verifier[:length]
|
||||||
}
|
}
|
||||||
@@ -199,15 +189,12 @@ func generatePKCEVerifier(length int) (string, error) {
|
|||||||
return verifier, nil
|
return verifier, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatePKCEChallenge generates a code challenge from verifier using S256 method
|
|
||||||
func generatePKCEChallenge(verifier string) string {
|
func generatePKCEChallenge(verifier string) string {
|
||||||
hash := sha256.Sum256([]byte(verifier))
|
hash := sha256.Sum256([]byte(verifier))
|
||||||
// Base64url encode without padding (RFC 7636)
|
// Base64url encode without padding (RFC 7636)
|
||||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// authGeneratePKCE generates a PKCE code verifier and challenge pair
|
|
||||||
// Returns: { verifier: string, challenge: string, method: "S256" }
|
|
||||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||||
// Default length is 64 characters
|
// Default length is 64 characters
|
||||||
length := 64
|
length := 64
|
||||||
@@ -227,7 +214,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
challenge := generatePKCEChallenge(verifier)
|
challenge := generatePKCEChallenge(verifier)
|
||||||
|
|
||||||
// Store in auth state for later use
|
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
state, exists := extensionAuthState[r.extensionID]
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -247,7 +233,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
|
|
||||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
@@ -405,7 +390,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get stored PKCE verifier
|
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
state, exists := extensionAuthState[r.extensionID]
|
state, exists := extensionAuthState[r.extensionID]
|
||||||
var verifier string
|
var verifier string
|
||||||
@@ -421,7 +405,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate domain
|
|
||||||
if err := r.validateDomain(tokenURL); err != nil {
|
if err := r.validateDomain(tokenURL); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -429,7 +412,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build token request body
|
|
||||||
formData := url.Values{}
|
formData := url.Values{}
|
||||||
formData.Set("grant_type", "authorization_code")
|
formData.Set("grant_type", "authorization_code")
|
||||||
formData.Set("client_id", clientID)
|
formData.Set("client_id", clientID)
|
||||||
@@ -439,14 +421,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
formData.Set("redirect_uri", redirectURI)
|
formData.Set("redirect_uri", redirectURI)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add extra params
|
|
||||||
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||||
for k, v := range extraParams {
|
for k, v := range extraParams {
|
||||||
formData.Set(k, fmt.Sprintf("%v", v))
|
formData.Set(k, fmt.Sprintf("%v", v))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make token request
|
|
||||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -475,7 +455,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response
|
|
||||||
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{}{
|
||||||
@@ -485,7 +464,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for error in response
|
|
||||||
if errMsg, ok := tokenResp["error"].(string); ok {
|
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||||
errDesc, _ := tokenResp["error_description"].(string)
|
errDesc, _ := tokenResp["error_description"].(string)
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -495,7 +473,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract tokens
|
|
||||||
accessToken, _ := tokenResp["access_token"].(string)
|
accessToken, _ := tokenResp["access_token"].(string)
|
||||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||||
@@ -508,7 +485,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store tokens in auth state
|
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
state, exists = extensionAuthState[r.extensionID]
|
state, exists = extensionAuthState[r.extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -521,14 +497,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
if expiresIn > 0 {
|
if expiresIn > 0 {
|
||||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||||
}
|
}
|
||||||
// Clear PKCE after successful exchange
|
|
||||||
state.PKCEVerifier = ""
|
state.PKCEVerifier = ""
|
||||||
state.PKCEChallenge = ""
|
state.PKCEChallenge = ""
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
|
|
||||||
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||||
|
|
||||||
// Return full token response
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
"access_token": accessToken,
|
"access_token": accessToken,
|
||||||
@@ -538,7 +512,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
if expiresIn > 0 {
|
if expiresIn > 0 {
|
||||||
result["expires_in"] = expiresIn
|
result["expires_in"] = expiresIn
|
||||||
}
|
}
|
||||||
// Include any additional fields from response
|
|
||||||
if scope, ok := tokenResp["scope"].(string); ok {
|
if scope, ok := tokenResp["scope"].(string); ok {
|
||||||
result["scope"] = scope
|
result["scope"] = scope
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,14 +31,12 @@ var (
|
|||||||
ffmpegCommandID int64
|
ffmpegCommandID int64
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
|
|
||||||
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||||
ffmpegCommandsMu.RLock()
|
ffmpegCommandsMu.RLock()
|
||||||
defer ffmpegCommandsMu.RUnlock()
|
defer ffmpegCommandsMu.RUnlock()
|
||||||
return ffmpegCommands[commandID]
|
return ffmpegCommands[commandID]
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
|
|
||||||
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||||
ffmpegCommandsMu.Lock()
|
ffmpegCommandsMu.Lock()
|
||||||
defer ffmpegCommandsMu.Unlock()
|
defer ffmpegCommandsMu.Unlock()
|
||||||
@@ -50,14 +48,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearFFmpegCommand removes a completed FFmpeg command
|
|
||||||
func ClearFFmpegCommand(commandID string) {
|
func ClearFFmpegCommand(commandID string) {
|
||||||
ffmpegCommandsMu.Lock()
|
ffmpegCommandsMu.Lock()
|
||||||
defer ffmpegCommandsMu.Unlock()
|
defer ffmpegCommandsMu.Unlock()
|
||||||
delete(ffmpegCommands, commandID)
|
delete(ffmpegCommands, commandID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ffmpegExecute queues an FFmpeg command for execution by Flutter
|
|
||||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -118,7 +114,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ffmpegGetInfo gets audio file information using FFprobe
|
|
||||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -147,7 +142,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ffmpegConvert is a helper for common conversion operations
|
|
||||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ var (
|
|||||||
allowedDownloadDirsMu sync.RWMutex
|
allowedDownloadDirsMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
|
|
||||||
// This should be called by the Go backend when setting up download paths
|
|
||||||
func SetAllowedDownloadDirs(dirs []string) {
|
func SetAllowedDownloadDirs(dirs []string) {
|
||||||
allowedDownloadDirsMu.Lock()
|
allowedDownloadDirsMu.Lock()
|
||||||
defer allowedDownloadDirsMu.Unlock()
|
defer allowedDownloadDirsMu.Unlock()
|
||||||
@@ -30,7 +28,6 @@ func SetAllowedDownloadDirs(dirs []string) {
|
|||||||
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAllowedDownloadDir adds a directory to the allowed list
|
|
||||||
func AddAllowedDownloadDir(dir string) {
|
func AddAllowedDownloadDir(dir string) {
|
||||||
allowedDownloadDirsMu.Lock()
|
allowedDownloadDirsMu.Lock()
|
||||||
defer allowedDownloadDirsMu.Unlock()
|
defer allowedDownloadDirsMu.Unlock()
|
||||||
@@ -40,7 +37,6 @@ func AddAllowedDownloadDir(dir string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
|
|
||||||
func isPathInAllowedDirs(absPath string) bool {
|
func isPathInAllowedDirs(absPath string) bool {
|
||||||
allowedDownloadDirsMu.RLock()
|
allowedDownloadDirsMu.RLock()
|
||||||
defer allowedDownloadDirsMu.RUnlock()
|
defer allowedDownloadDirsMu.RUnlock()
|
||||||
@@ -62,36 +58,28 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
|||||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean and resolve the path
|
|
||||||
cleanPath := filepath.Clean(path)
|
cleanPath := filepath.Clean(path)
|
||||||
|
|
||||||
// SECURITY: Block absolute paths by default
|
|
||||||
// Only allow if path is in explicitly allowed download directories
|
|
||||||
if filepath.IsAbs(cleanPath) {
|
if filepath.IsAbs(cleanPath) {
|
||||||
absPath, err := filepath.Abs(cleanPath)
|
absPath, err := filepath.Abs(cleanPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid path: %w", err)
|
return "", fmt.Errorf("invalid path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if path is in allowed download directories
|
|
||||||
if isPathInAllowedDirs(absPath) {
|
if isPathInAllowedDirs(absPath) {
|
||||||
return absPath, nil
|
return absPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block all other absolute paths
|
|
||||||
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
||||||
}
|
}
|
||||||
|
|
||||||
// For relative paths, join with data directory (extension's sandbox)
|
|
||||||
fullPath := filepath.Join(r.dataDir, cleanPath)
|
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||||
|
|
||||||
// Resolve to absolute path
|
|
||||||
absPath, err := filepath.Abs(fullPath)
|
absPath, err := filepath.Abs(fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid path: %w", err)
|
return "", fmt.Errorf("invalid path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure path is within data directory (prevent path traversal)
|
|
||||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||||
if !strings.HasPrefix(absPath, absDataDir) {
|
if !strings.HasPrefix(absPath, absDataDir) {
|
||||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||||
@@ -100,8 +88,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
|||||||
return absPath, nil
|
return absPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileDownload downloads a file from URL to the specified path
|
|
||||||
// Supports progress callback via options.onProgress
|
|
||||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -113,7 +99,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
urlStr := call.Arguments[0].String()
|
urlStr := call.Arguments[0].String()
|
||||||
outputPath := call.Arguments[1].String()
|
outputPath := call.Arguments[1].String()
|
||||||
|
|
||||||
// Validate domain
|
|
||||||
if err := r.validateDomain(urlStr); err != nil {
|
if err := r.validateDomain(urlStr); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -121,7 +106,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate output path (allows absolute paths for download queue)
|
|
||||||
fullPath, err := r.validatePath(outputPath)
|
fullPath, err := r.validatePath(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -130,20 +114,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get options if provided
|
|
||||||
var onProgress goja.Callable
|
var onProgress goja.Callable
|
||||||
var headers map[string]string
|
var headers 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]) {
|
||||||
optionsObj := call.Arguments[2].Export()
|
optionsObj := call.Arguments[2].Export()
|
||||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
// Extract headers
|
|
||||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||||
headers = make(map[string]string)
|
headers = make(map[string]string)
|
||||||
for k, v := range h {
|
for k, v := range h {
|
||||||
headers[k] = fmt.Sprintf("%v", v)
|
headers[k] = fmt.Sprintf("%v", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Extract onProgress callback
|
|
||||||
if progressVal, ok := opts["onProgress"]; ok {
|
if progressVal, ok := opts["onProgress"]; ok {
|
||||||
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||||
onProgress = callable
|
onProgress = callable
|
||||||
@@ -152,7 +133,6 @@ func (r *ExtensionRuntime) fileDownload(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{}{
|
||||||
@@ -161,7 +141,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create HTTP request
|
|
||||||
req, err := http.NewRequest("GET", urlStr, nil)
|
req, err := http.NewRequest("GET", urlStr, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -170,7 +149,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
@@ -178,7 +156,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file
|
|
||||||
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{}{
|
||||||
@@ -195,7 +172,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output file
|
|
||||||
out, err := os.Create(fullPath)
|
out, err := os.Create(fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -205,12 +181,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Get content length for progress
|
|
||||||
contentLength := resp.ContentLength
|
contentLength := resp.ContentLength
|
||||||
|
|
||||||
// Copy content with progress reporting
|
|
||||||
var written int64
|
var written int64
|
||||||
buf := make([]byte, 32*1024) // 32KB buffer
|
buf := make([]byte, 32*1024)
|
||||||
for {
|
for {
|
||||||
nr, er := resp.Body.Read(buf)
|
nr, er := resp.Body.Read(buf)
|
||||||
if nr > 0 {
|
if nr > 0 {
|
||||||
@@ -235,7 +209,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report progress
|
|
||||||
if onProgress != nil && contentLength > 0 {
|
if onProgress != nil && contentLength > 0 {
|
||||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||||
}
|
}
|
||||||
@@ -260,7 +233,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileExists checks if a file exists in the sandbox
|
|
||||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
@@ -276,7 +248,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(err == nil)
|
return r.vm.ToValue(err == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileDelete deletes a file in the sandbox
|
|
||||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -306,7 +277,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileRead reads a file from the sandbox
|
|
||||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -338,7 +308,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileWrite writes data to a file in the sandbox
|
|
||||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -380,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileCopy copies a file within the sandbox
|
|
||||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -408,7 +376,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read source file
|
|
||||||
data, err := os.ReadFile(fullSrc)
|
data, err := os.ReadFile(fullSrc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -417,7 +384,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination directory if needed
|
|
||||||
dir := filepath.Dir(fullDst)
|
dir := filepath.Dir(fullDst)
|
||||||
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{}{
|
||||||
@@ -426,7 +392,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to destination
|
|
||||||
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -440,7 +405,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileMove moves/renames a file within the sandbox
|
|
||||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -468,7 +432,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination directory if needed
|
|
||||||
dir := filepath.Dir(fullDst)
|
dir := filepath.Dir(fullDst)
|
||||||
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{}{
|
||||||
@@ -490,7 +453,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileGetSize returns the size of a file in bytes
|
|
||||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ func (r *ExtensionRuntime) httpGet(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] HTTP blocked: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -60,7 +59,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get headers if provided
|
|
||||||
headers := make(map[string]string)
|
headers := make(map[string]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]) {
|
||||||
headersObj := call.Arguments[1].Export()
|
headersObj := call.Arguments[1].Export()
|
||||||
@@ -71,7 +69,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create request
|
|
||||||
req, err := http.NewRequest("GET", urlStr, nil)
|
req, err := http.NewRequest("GET", urlStr, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -97,7 +94,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
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.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -134,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(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] HTTP blocked: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -175,7 +170,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create request
|
|
||||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -204,7 +198,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
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.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -231,8 +224,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
|
||||||
// Usage: http.request(url, options) where options = { method, body, headers }
|
|
||||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -242,7 +233,6 @@ func (r *ExtensionRuntime) httpRequest(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] HTTP blocked: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -326,7 +316,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
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.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -354,7 +343,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
|
|
||||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PUT", call)
|
return r.httpMethodShortcut("PUT", call)
|
||||||
}
|
}
|
||||||
@@ -364,7 +352,6 @@ func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
|||||||
return r.httpMethodShortcut("DELETE", call)
|
return r.httpMethodShortcut("DELETE", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
|
|
||||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PATCH", call)
|
return r.httpMethodShortcut("PATCH", call)
|
||||||
}
|
}
|
||||||
@@ -380,7 +367,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
|
|
||||||
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] HTTP blocked: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -465,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
}
|
}
|
||||||
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.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -492,7 +477,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpClearCookies clears all cookies for this extension
|
|
||||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||||
jar.mu.Lock()
|
jar.mu.Lock()
|
||||||
|
|||||||
@@ -143,19 +143,16 @@ func (r *ExtensionRuntime) getSaltPath() string {
|
|||||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||||
saltPath := r.getSaltPath()
|
saltPath := r.getSaltPath()
|
||||||
|
|
||||||
// Try to read existing salt
|
|
||||||
salt, err := os.ReadFile(saltPath)
|
salt, err := os.ReadFile(saltPath)
|
||||||
if err == nil && len(salt) == 32 {
|
if err == nil && len(salt) == 32 {
|
||||||
return salt, nil
|
return salt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new random salt (32 bytes)
|
|
||||||
salt = make([]byte, 32)
|
salt = make([]byte, 32)
|
||||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save salt to file
|
|
||||||
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||||
return nil, fmt.Errorf("failed to save salt: %w", err)
|
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||||
}
|
}
|
||||||
@@ -214,7 +211,6 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt the data
|
|
||||||
key, err := r.getEncryptionKey()
|
key, err := r.getEncryptionKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
@@ -94,7 +95,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue([]byte{})
|
return r.vm.ToValue([]byte{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get key - can be string or array of bytes
|
|
||||||
var keyBytes []byte
|
var keyBytes []byte
|
||||||
keyArg := call.Arguments[0].Export()
|
keyArg := call.Arguments[0].Export()
|
||||||
switch k := keyArg.(type) {
|
switch k := keyArg.(type) {
|
||||||
@@ -113,7 +113,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue([]byte{})
|
return r.vm.ToValue([]byte{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get message - can be string or array of bytes
|
|
||||||
var msgBytes []byte
|
var msgBytes []byte
|
||||||
msgArg := call.Arguments[1].Export()
|
msgArg := call.Arguments[1].Export()
|
||||||
switch m := msgArg.(type) {
|
switch m := msgArg.(type) {
|
||||||
@@ -136,7 +135,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
|||||||
mac.Write(msgBytes)
|
mac.Write(msgBytes)
|
||||||
result := mac.Sum(nil)
|
result := mac.Sum(nil)
|
||||||
|
|
||||||
// Convert to array of numbers for JavaScript
|
|
||||||
jsArray := make([]interface{}, len(result))
|
jsArray := make([]interface{}, len(result))
|
||||||
for i, b := range result {
|
for i, b := range result {
|
||||||
jsArray[i] = int(b)
|
jsArray[i] = int(b)
|
||||||
@@ -268,6 +266,11 @@ 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 {
|
||||||
|
return r.vm.ToValue(getRandomUserAgent())
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Logging Functions ====================
|
// ==================== Logging Functions ====================
|
||||||
|
|
||||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||||
@@ -369,4 +372,24 @@ func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
|
|
||||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Expose getLocalTime - returns device local time info
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
|||||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all existing settings
|
|
||||||
return s.loadAllSettings()
|
return s.loadAllSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +98,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
|
|||||||
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)
|
||||||
|
|
||||||
// Create directory if needed
|
|
||||||
dir := filepath.Dir(settingsPath)
|
dir := filepath.Dir(settingsPath)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -160,7 +158,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
|
|||||||
|
|
||||||
s.settings[extensionID][key] = value
|
s.settings[extensionID][key] = value
|
||||||
|
|
||||||
// Persist to disk
|
|
||||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +195,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
|||||||
|
|
||||||
delete(s.settings, extensionID)
|
delete(s.settings, extensionID)
|
||||||
|
|
||||||
// Remove settings file
|
|
||||||
settingsPath := s.getSettingsPath(extensionID)
|
settingsPath := s.getSettingsPath(extensionID)
|
||||||
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ type StoreExtension struct {
|
|||||||
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"`
|
||||||
// Alternative camelCase fields (for flexibility)
|
|
||||||
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"`
|
||||||
@@ -332,7 +331,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination file
|
|
||||||
out, err := os.Create(destPath)
|
out, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
|
|||||||
@@ -6,28 +6,21 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invalid filename characters for Android/Windows/Linux
|
|
||||||
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||||
|
|
||||||
// sanitizeFilename removes invalid characters from filename
|
|
||||||
func sanitizeFilename(filename string) string {
|
func sanitizeFilename(filename string) string {
|
||||||
// Replace invalid characters with underscore
|
|
||||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||||
|
|
||||||
// Remove leading/trailing spaces and dots
|
|
||||||
sanitized = strings.TrimSpace(sanitized)
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
sanitized = strings.Trim(sanitized, ".")
|
sanitized = strings.Trim(sanitized, ".")
|
||||||
|
|
||||||
// Collapse multiple underscores
|
|
||||||
multiUnderscore := regexp.MustCompile(`_+`)
|
multiUnderscore := regexp.MustCompile(`_+`)
|
||||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||||
|
|
||||||
// Limit length (Android has 255 byte limit for filenames)
|
|
||||||
if len(sanitized) > 200 {
|
if len(sanitized) > 200 {
|
||||||
sanitized = sanitized[:200]
|
sanitized = sanitized[:200]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure not empty
|
|
||||||
if sanitized == "" {
|
if sanitized == "" {
|
||||||
sanitized = "untitled"
|
sanitized = "untitled"
|
||||||
}
|
}
|
||||||
@@ -35,7 +28,6 @@ func sanitizeFilename(filename string) string {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFilenameFromTemplate builds a filename from template and metadata
|
|
||||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||||
if template == "" {
|
if template == "" {
|
||||||
template = "{artist} - {title}"
|
template = "{artist} - {title}"
|
||||||
@@ -43,7 +35,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
|
|
||||||
result := template
|
result := template
|
||||||
|
|
||||||
// Replace placeholders
|
|
||||||
placeholders := map[string]string{
|
placeholders := map[string]string{
|
||||||
"{title}": getString(metadata, "title"),
|
"{title}": getString(metadata, "title"),
|
||||||
"{artist}": getString(metadata, "artist"),
|
"{artist}": getString(metadata, "artist"),
|
||||||
@@ -63,7 +54,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
func getString(m map[string]interface{}, key string) string {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if v, ok := m[key]; ok {
|
if v, ok := m[key]; ok {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
// Trim leading/trailing whitespace to prevent filename issues
|
|
||||||
return strings.TrimSpace(s)
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +88,6 @@ func formatDiscNumber(n int) string {
|
|||||||
return fmt.Sprintf("%d", n)
|
return fmt.Sprintf("%d", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
|
|
||||||
func extractYear(date string) string {
|
func extractYear(date string) string {
|
||||||
if len(date) >= 4 {
|
if len(date) >= 4 {
|
||||||
return date[:4]
|
return date[:4]
|
||||||
|
|||||||
@@ -1,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.6
|
||||||
|
|
||||||
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 v0.3.0
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
|
github.com/refraction-networking/utls v1.8.2
|
||||||
|
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4
|
||||||
|
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,5 +1,7 @@
|
|||||||
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/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=
|
||||||
@@ -12,17 +14,29 @@ github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY
|
|||||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||||
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/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
|
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-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU=
|
||||||
|
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I=
|
||||||
|
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=
|
||||||
|
|||||||
@@ -15,76 +15,33 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP utility functions for consistent request handling across all downloaders
|
|
||||||
|
|
||||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||||
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
// 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 {
|
||||||
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
// Chrome version 120-145 (modern range)
|
||||||
// Some APIs may block mobile User-Agents, so we use desktop format
|
chromeVersion := rand.Intn(26) + 120
|
||||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
chromeBuild := rand.Intn(1500) + 6000
|
||||||
|
chromePatch := rand.Intn(200) + 100
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
|
||||||
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
|
||||||
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||||
winMajor,
|
|
||||||
chromeVersion,
|
chromeVersion,
|
||||||
chromeBuild,
|
chromeBuild,
|
||||||
chromePatch,
|
chromePatch,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
|
||||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
|
||||||
// Kept for potential future use
|
|
||||||
// func getRandomMacUserAgent() string {
|
|
||||||
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
|
||||||
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
|
||||||
// webkitMajor := rand.Intn(7) + 530
|
|
||||||
// webkitMinor := rand.Intn(7) + 30
|
|
||||||
// chromeMajor := rand.Intn(25) + 80
|
|
||||||
// chromeBuild := rand.Intn(1500) + 3000
|
|
||||||
// chromePatch := rand.Intn(65) + 60
|
|
||||||
// safariMajor := rand.Intn(7) + 530
|
|
||||||
// safariMinor := rand.Intn(6) + 30
|
|
||||||
//
|
|
||||||
// return fmt.Sprintf(
|
|
||||||
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
|
||||||
// macMajor,
|
|
||||||
// macMinor,
|
|
||||||
// webkitMajor,
|
|
||||||
// webkitMinor,
|
|
||||||
// chromeMajor,
|
|
||||||
// chromeBuild,
|
|
||||||
// chromePatch,
|
|
||||||
// safariMajor,
|
|
||||||
// safariMinor,
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
|
||||||
// Kept for potential future use
|
|
||||||
// func getRandomDesktopUserAgent() string {
|
|
||||||
// if rand.Intn(2) == 0 {
|
|
||||||
// return getRandomUserAgent() // Windows
|
|
||||||
// }
|
|
||||||
// return getRandomMacUserAgent() // Mac
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Default timeout values
|
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
DefaultTimeout = 60 * time.Second
|
||||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
DownloadTimeout = 120 * time.Second
|
||||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3 // Default retry count
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
DefaultRetryDelay = 1 * time.Second
|
||||||
|
Second = time.Second // Exported for use in other files
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
// Optimized for large file downloads (FLAC ~30-50MB)
|
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -96,27 +53,23 @@ var sharedTransport = &http.Transport{
|
|||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
DisableKeepAlives: false,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
WriteBufferSize: 64 * 1024,
|
||||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
ReadBufferSize: 64 * 1024,
|
||||||
DisableCompression: true, // FLAC is already compressed
|
DisableCompression: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for general requests (reuses connections)
|
|
||||||
var sharedClient = &http.Client{
|
var sharedClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DefaultTimeout,
|
Timeout: DefaultTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for downloads (longer timeout, reuses connections)
|
|
||||||
var downloadClient = &http.Client{
|
var downloadClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DownloadTimeout,
|
Timeout: DownloadTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
|
||||||
// Uses shared transport for connection reuse
|
|
||||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
@@ -124,29 +77,24 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSharedClient returns the shared HTTP client for general requests
|
|
||||||
func GetSharedClient() *http.Client {
|
func GetSharedClient() *http.Client {
|
||||||
return sharedClient
|
return sharedClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadClient returns the shared HTTP client for downloads
|
|
||||||
func GetDownloadClient() *http.Client {
|
func GetDownloadClient() *http.Client {
|
||||||
return downloadClient
|
return downloadClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseIdleConnections closes idle connections in the shared transport
|
// CloseIdleConnections closes idle connections in the shared transport
|
||||||
// Call this periodically during large batch downloads to prevent connection buildup
|
|
||||||
func CloseIdleConnections() {
|
func CloseIdleConnections() {
|
||||||
sharedTransport.CloseIdleConnections()
|
sharedTransport.CloseIdleConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
|
||||||
// Also checks for ISP blocking on errors
|
// Also checks for ISP blocking on errors
|
||||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check for ISP blocking
|
|
||||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
}
|
}
|
||||||
return resp, err
|
return resp, err
|
||||||
@@ -160,7 +108,6 @@ type RetryConfig struct {
|
|||||||
BackoffFactor float64
|
BackoffFactor float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRetryConfig returns default retry configuration
|
|
||||||
func DefaultRetryConfig() RetryConfig {
|
func DefaultRetryConfig() RetryConfig {
|
||||||
return RetryConfig{
|
return RetryConfig{
|
||||||
MaxRetries: DefaultMaxRetries,
|
MaxRetries: DefaultMaxRetries,
|
||||||
@@ -266,13 +213,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
|
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateNextDelay calculates the next delay with exponential backoff
|
|
||||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||||
return min(nextDelay, config.MaxDelay)
|
return min(nextDelay, config.MaxDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
|
||||||
// Returns 60 seconds as default if header is missing or invalid
|
// Returns 60 seconds as default if header is missing or invalid
|
||||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||||
retryAfter := resp.Header.Get("Retry-After")
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
@@ -315,7 +260,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateResponse checks if response is valid (non-nil, status 2xx)
|
|
||||||
func ValidateResponse(resp *http.Response) error {
|
func ValidateResponse(resp *http.Response) error {
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
return fmt.Errorf("response is nil")
|
return fmt.Errorf("response is nil")
|
||||||
@@ -344,7 +288,6 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISPBlockingError represents an error caused by ISP blocking
|
|
||||||
type ISPBlockingError struct {
|
type ISPBlockingError struct {
|
||||||
Domain string
|
Domain string
|
||||||
Reason string
|
Reason string
|
||||||
@@ -460,7 +403,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
|
|
||||||
// Returns true if ISP blocking was detected
|
// Returns true if ISP blocking was detected
|
||||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
ispErr := IsISPBlocking(err, requestURL)
|
ispErr := IsISPBlocking(err, requestURL)
|
||||||
@@ -498,7 +440,6 @@ func extractDomain(rawURL string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
|
|
||||||
// If ISP blocking is detected, returns a more descriptive error
|
// If ISP blocking is detected, returns a more descriptive error
|
||||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -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,188 @@
|
|||||||
|
//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) {
|
||||||
|
// For non-HTTPS, use standard transport
|
||||||
|
if req.URL.Scheme != "https" {
|
||||||
|
return sharedTransport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := req.URL.Hostname()
|
||||||
|
port := t.getPort(req.URL)
|
||||||
|
addr := net.JoinHostPort(host, port)
|
||||||
|
|
||||||
|
// Dial TCP connection
|
||||||
|
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create uTLS connection with Chrome fingerprint (supports HTTP/2 ALPN)
|
||||||
|
tlsConn := utls.UClient(conn, &utls.Config{
|
||||||
|
ServerName: host,
|
||||||
|
NextProtos: []string{"h2", "http/1.1"}, // Prefer HTTP/2
|
||||||
|
}, utls.HelloChrome_Auto)
|
||||||
|
|
||||||
|
// Perform TLS handshake
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server supports HTTP/2
|
||||||
|
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||||
|
|
||||||
|
if negotiatedProto == "h2" {
|
||||||
|
// Use HTTP/2 transport
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to HTTP/1.1
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogEntry represents a single log entry
|
|
||||||
type LogEntry struct {
|
type LogEntry struct {
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
@@ -16,12 +15,11 @@ type LogEntry struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
|
|
||||||
type LogBuffer struct {
|
type LogBuffer struct {
|
||||||
entries []LogEntry
|
entries []LogEntry
|
||||||
maxSize int
|
maxSize int
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
|
loggingEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer {
|
|||||||
return globalLogBuffer
|
return globalLogBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLoggingEnabled enables or disables logging
|
|
||||||
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()
|
||||||
@@ -55,12 +52,10 @@ func (lb *LogBuffer) IsLoggingEnabled() bool {
|
|||||||
return lb.loggingEnabled
|
return lb.loggingEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds a log entry to the buffer
|
|
||||||
func (lb *LogBuffer) Add(level, tag, message string) {
|
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
|
|
||||||
// Skip if logging is disabled (except for errors which are always logged)
|
|
||||||
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -73,12 +68,10 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(lb.entries) >= lb.maxSize {
|
if len(lb.entries) >= lb.maxSize {
|
||||||
// Remove oldest entry
|
|
||||||
lb.entries = lb.entries[1:]
|
lb.entries = lb.entries[1:]
|
||||||
}
|
}
|
||||||
lb.entries = append(lb.entries, entry)
|
lb.entries = append(lb.entries, entry)
|
||||||
|
|
||||||
// Also print to logcat for debugging
|
|
||||||
fmt.Printf("[%s] %s\n", tag, message)
|
fmt.Printf("[%s] %s\n", tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +84,6 @@ func (lb *LogBuffer) GetAll() string {
|
|||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSince returns log entries since the given index (internal use)
|
|
||||||
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
@@ -158,11 +150,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"
|
||||||
|
|||||||
@@ -3,14 +3,93 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lyricsCacheTTL = 24 * time.Hour
|
||||||
|
durationToleranceSec = 10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
type lyricsCacheEntry struct {
|
||||||
|
response *LyricsResponse
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]*lyricsCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalLyricsCache = &lyricsCache{
|
||||||
|
cache: make(map[string]*lyricsCacheEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
|
||||||
|
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
|
||||||
|
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
|
||||||
|
roundedDuration := math.Round(durationSec/10) * 10
|
||||||
|
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
key := c.generateKey(artist, track, durationSec)
|
||||||
|
entry, exists := c.cache[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(entry.expiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.response, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
key := c.generateKey(artist, track, durationSec)
|
||||||
|
c.cache[key] = &lyricsCacheEntry{
|
||||||
|
response: response,
|
||||||
|
expiresAt: time.Now().Add(lyricsCacheTTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) CleanExpired() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cleaned := 0
|
||||||
|
for key, entry := range c.cache {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(c.cache, key)
|
||||||
|
cleaned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Size() int {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return len(c.cache)
|
||||||
|
}
|
||||||
|
|
||||||
type LRCLibResponse struct {
|
type LRCLibResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -44,9 +123,7 @@ type LyricsClient struct {
|
|||||||
|
|
||||||
func NewLyricsClient() *LyricsClient {
|
func NewLyricsClient() *LyricsClient {
|
||||||
return &LyricsClient{
|
return &LyricsClient{
|
||||||
httpClient: &http.Client{
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
Timeout: 15 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +163,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
|||||||
return c.parseLRCLibResponse(&lrcResp), nil
|
return c.parseLRCLibResponse(&lrcResp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
|
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
|
||||||
baseURL := "https://lrclib.net/api/search"
|
baseURL := "https://lrclib.net/api/search"
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("q", query)
|
params.Set("q", query)
|
||||||
@@ -118,6 +195,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
|||||||
return nil, fmt.Errorf("no lyrics found")
|
return nil, fmt.Errorf("no lyrics found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bestMatch := c.findBestMatch(results, durationSec)
|
||||||
|
if bestMatch != nil {
|
||||||
|
return c.parseLRCLibResponse(bestMatch), nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
if result.SyncedLyrics != "" {
|
if result.SyncedLyrics != "" {
|
||||||
return c.parseLRCLibResponse(&result), nil
|
return c.parseLRCLibResponse(&result), nil
|
||||||
@@ -127,38 +209,101 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
|||||||
return c.parseLRCLibResponse(&results[0]), nil
|
return c.parseLRCLibResponse(&results[0]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
|
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||||
// Strategy 1: Direct match with artist and track name
|
var bestSynced *LRCLibResponse
|
||||||
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
|
var bestPlain *LRCLibResponse
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
|
||||||
|
for i := range results {
|
||||||
|
result := &results[i]
|
||||||
|
|
||||||
|
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
|
||||||
|
|
||||||
|
if durationMatches {
|
||||||
|
if result.SyncedLyrics != "" && bestSynced == nil {
|
||||||
|
bestSynced = result
|
||||||
|
} else if result.PlainLyrics != "" && bestPlain == nil {
|
||||||
|
bestPlain = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestSynced != nil {
|
||||||
|
return bestSynced
|
||||||
|
}
|
||||||
|
return bestPlain
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||||
|
diff := math.Abs(lrcDuration - targetDuration)
|
||||||
|
return diff <= durationToleranceSec
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Normalize artist name - take first artist before comma/semicolon for better matching
|
||||||
|
primaryArtist := normalizeArtistName(artistName)
|
||||||
|
|
||||||
|
// Check cache first (use original artist name for cache key)
|
||||||
|
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||||
|
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||||
|
cachedCopy := *cached
|
||||||
|
cachedCopy.Source = cached.Source + " (cached)"
|
||||||
|
return &cachedCopy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyrics *LyricsResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Helper to check if lyrics result is valid (has lines OR is instrumental)
|
||||||
|
isValidResult := func(l *LyricsResponse) bool {
|
||||||
|
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match first with primary artist
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Try with simplified track name
|
// Try with full artist name if different from primary
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
if primaryArtist != artistName {
|
||||||
if simplifiedTrack != trackName {
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
if err == nil && isValidResult(lyrics) {
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
lyrics.Source = "LRCLIB"
|
||||||
lyrics.Source = "LRCLIB (simplified)"
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Search with full query
|
// Try with simplified track name
|
||||||
query := artistName + " " + trackName
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
if simplifiedTrack != trackName {
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
|
lyrics.Source = "LRCLIB (simplified)"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with duration matching (use primary artist for search)
|
||||||
|
query := primaryArtist + " " + trackName
|
||||||
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB Search"
|
lyrics.Source = "LRCLIB Search"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 4: Search with simplified query
|
// Search with simplified name and duration matching
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = artistName + " " + simplifiedTrack
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && isValidResult(lyrics) {
|
||||||
lyrics.Source = "LRCLIB Search (simplified)"
|
lyrics.Source = "LRCLIB Search (simplified)"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +393,6 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
|
||||||
// Use convertToLRCWithMetadata for full LRC with headers
|
// Use convertToLRCWithMetadata for full LRC with headers
|
||||||
// Kept for potential future use
|
// Kept for potential future use
|
||||||
// func convertToLRC(lyrics *LyricsResponse) string {
|
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||||
@@ -275,8 +419,6 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
// return builder.String()
|
// return builder.String()
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
|
||||||
// Includes [ti:], [ar:], [by:] headers
|
|
||||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -284,13 +426,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
|||||||
|
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
|
|
||||||
// Add metadata headers
|
|
||||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
|
|
||||||
// Add lyrics lines
|
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
for _, line := range lyrics.Lines {
|
for _, line := range lyrics.Lines {
|
||||||
if line.Words == "" {
|
if line.Words == "" {
|
||||||
@@ -339,3 +479,40 @@ func simplifyTrackName(name string) string {
|
|||||||
|
|
||||||
return strings.TrimSpace(result)
|
return strings.TrimSpace(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeArtistName extracts the primary artist from multi-artist strings
|
||||||
|
// e.g., "HOYO-MiX, AURORA" -> "HOYO-MiX"
|
||||||
|
// e.g., "Artist1; Artist2" -> "Artist1"
|
||||||
|
func normalizeArtistName(name string) string {
|
||||||
|
// Split by common separators: ", " or "; " or " & " or " feat. " or " ft. "
|
||||||
|
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
|
||||||
|
|
||||||
|
result := name
|
||||||
|
for _, sep := range separators {
|
||||||
|
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
|
||||||
|
result = result[:idx]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||||
|
if lrcContent == "" {
|
||||||
|
return "", fmt.Errorf("empty LRC content")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Dir(audioFilePath)
|
||||||
|
ext := filepath.Ext(audioFilePath)
|
||||||
|
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
|
||||||
|
|
||||||
|
lrcFilePath := filepath.Join(dir, baseName+".lrc")
|
||||||
|
|
||||||
|
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write LRC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath)
|
||||||
|
return lrcFilePath, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,7 +14,6 @@ import (
|
|||||||
"github.com/go-flac/go-flac"
|
"github.com/go-flac/go-flac"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata represents track metadata for embedding
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
Title string
|
Title string
|
||||||
Artist string
|
Artist string
|
||||||
@@ -24,16 +26,17 @@ type Metadata struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
Description string
|
Description string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
|
Genre string
|
||||||
|
Label string
|
||||||
|
Copyright string
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedMetadata embeds metadata into a FLAC file
|
|
||||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create vorbis comment block
|
|
||||||
var cmtIdx int = -1
|
var cmtIdx int = -1
|
||||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
@@ -52,7 +55,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
cmt = flacvorbis.New()
|
cmt = flacvorbis.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set metadata fields
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setComment(cmt, "ARTIST", metadata.Artist)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
@@ -84,7 +86,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or add vorbis comment block
|
if metadata.Genre != "" {
|
||||||
|
setComment(cmt, "GENRE", metadata.Genre)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Label != "" {
|
||||||
|
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Copyright != "" {
|
||||||
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -92,14 +105,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
f.Meta = append(f.Meta, &cmtBlock)
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cover art if provided
|
|
||||||
if coverPath != "" {
|
if coverPath != "" {
|
||||||
if fileExists(coverPath) {
|
if fileExists(coverPath) {
|
||||||
coverData, err := os.ReadFile(coverPath)
|
coverData, err := os.ReadFile(coverPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
||||||
} else {
|
} else {
|
||||||
// Remove existing picture blocks first (like PC version)
|
|
||||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||||
if f.Meta[i].Type == flac.Picture {
|
if f.Meta[i].Type == flac.Picture {
|
||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
@@ -125,19 +136,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes
|
|
||||||
// This avoids file permission issues on Android by not requiring a temp file
|
|
||||||
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
|
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create vorbis comment block
|
|
||||||
var cmtIdx int = -1
|
var cmtIdx int = -1
|
||||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
@@ -156,7 +163,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
cmt = flacvorbis.New()
|
cmt = flacvorbis.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set metadata fields
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setComment(cmt, "ARTIST", metadata.Artist)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
@@ -188,7 +194,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or add vorbis comment block
|
if metadata.Genre != "" {
|
||||||
|
setComment(cmt, "GENRE", metadata.Genre)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Label != "" {
|
||||||
|
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Copyright != "" {
|
||||||
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -196,9 +213,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
f.Meta = append(f.Meta, &cmtBlock)
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cover art if provided
|
|
||||||
if len(coverData) > 0 {
|
if len(coverData) > 0 {
|
||||||
// Remove existing picture blocks first
|
|
||||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||||
if f.Meta[i].Type == flac.Picture {
|
if f.Meta[i].Type == flac.Picture {
|
||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
@@ -220,7 +235,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +271,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
}
|
}
|
||||||
// Also try lowercase variant (some encoders use lowercase)
|
|
||||||
if metadata.TrackNumber == 0 {
|
if metadata.TrackNumber == 0 {
|
||||||
trackNum = getComment(cmt, "TRACK")
|
trackNum = getComment(cmt, "TRACK")
|
||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
@@ -269,7 +282,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
}
|
}
|
||||||
// Also try DISC variant
|
|
||||||
if metadata.DiscNumber == 0 {
|
if metadata.DiscNumber == 0 {
|
||||||
discNum = getComment(cmt, "DISC")
|
discNum = getComment(cmt, "DISC")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
@@ -277,7 +289,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try DATE variants
|
|
||||||
if metadata.Date == "" {
|
if metadata.Date == "" {
|
||||||
metadata.Date = getComment(cmt, "YEAR")
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
}
|
}
|
||||||
@@ -293,7 +304,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing (case-insensitive comparison for Vorbis comments)
|
|
||||||
keyUpper := strings.ToUpper(key)
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
comment := cmt.Comments[i]
|
comment := cmt.Comments[i]
|
||||||
@@ -305,7 +315,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add new
|
|
||||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +322,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
|||||||
keyUpper := strings.ToUpper(key) + "="
|
keyUpper := strings.ToUpper(key) + "="
|
||||||
for _, comment := range cmt.Comments {
|
for _, comment := range cmt.Comments {
|
||||||
if len(comment) > len(key) {
|
if len(comment) > len(key) {
|
||||||
// Case-insensitive comparison for Vorbis comments
|
|
||||||
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||||
if commentUpper == keyUpper {
|
if commentUpper == keyUpper {
|
||||||
return comment[len(key)+1:]
|
return comment[len(key)+1:]
|
||||||
@@ -323,13 +331,11 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileExists checks if a file exists
|
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation
|
|
||||||
func EmbedLyrics(filePath string, lyrics string) error {
|
func EmbedLyrics(filePath string, lyrics string) error {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -367,6 +373,51 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
|||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||||
|
if genre == "" && label == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmtIdx int = -1
|
||||||
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
|
for idx, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.VorbisComment {
|
||||||
|
cmtIdx = idx
|
||||||
|
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmt == nil {
|
||||||
|
cmt = flacvorbis.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
if genre != "" {
|
||||||
|
setComment(cmt, "GENRE", genre)
|
||||||
|
}
|
||||||
|
if label != "" {
|
||||||
|
setComment(cmt, "ORGANIZATION", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmtBlock := cmt.Marshal()
|
||||||
|
if cmtIdx >= 0 {
|
||||||
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
|
} else {
|
||||||
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Save(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
@@ -381,13 +432,11 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try LYRICS tag first
|
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to UNSYNCEDLYRICS
|
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
@@ -398,16 +447,12 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
return "", fmt.Errorf("no lyrics found in file")
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioQuality represents audio quality info from a FLAC file
|
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
TotalSamples int64 `json:"total_samples"`
|
TotalSamples int64 `json:"total_samples"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
|
||||||
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
|
||||||
// For M4A files, it delegates to GetM4AQuality
|
|
||||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -415,16 +460,12 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Read first 4 bytes to detect file type
|
|
||||||
marker := make([]byte, 4)
|
marker := make([]byte, 4)
|
||||||
if _, err := file.Read(marker); err != nil {
|
if _, err := file.Read(marker); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a FLAC file
|
|
||||||
if string(marker) == "fLaC" {
|
if string(marker) == "fLaC" {
|
||||||
// Continue reading FLAC metadata
|
|
||||||
// Read metadata block header (4 bytes)
|
|
||||||
header := make([]byte, 4)
|
header := make([]byte, 4)
|
||||||
if _, err := file.Read(header); err != nil {
|
if _, err := file.Read(header); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
@@ -435,19 +476,15 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read STREAMINFO block (34 bytes minimum)
|
|
||||||
streamInfo := make([]byte, 34)
|
streamInfo := make([]byte, 34)
|
||||||
if _, err := file.Read(streamInfo); err != nil {
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse sample rate (20 bits starting at byte 10)
|
|
||||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||||
|
|
||||||
// Parse bits per sample (5 bits)
|
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||||
|
|
||||||
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
|
|
||||||
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||||
int64(streamInfo[14])<<24 |
|
int64(streamInfo[14])<<24 |
|
||||||
int64(streamInfo[15])<<16 |
|
int64(streamInfo[15])<<16 |
|
||||||
@@ -461,17 +498,14 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
file.Seek(0, 0)
|
||||||
// First 4 bytes are size, next 4 should be "ftyp"
|
|
||||||
file.Seek(0, 0) // Reset to beginning
|
|
||||||
header8 := make([]byte, 8)
|
header8 := make([]byte, 8)
|
||||||
if _, err := file.Read(header8); err != nil {
|
if _, err := file.Read(header8); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(header8[4:8]) == "ftyp" {
|
if string(header8[4:8]) == "ftyp" {
|
||||||
// It's an M4A/MP4 file, use M4A quality reader
|
file.Close()
|
||||||
file.Close() // Close before calling GetM4AQuality which opens the file again
|
|
||||||
return GetM4AQuality(filePath)
|
return GetM4AQuality(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,157 +517,211 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||||
// This is a simplified implementation that writes metadata to the file
|
|
||||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
// Read the entire file
|
input, err := os.Open(filePath)
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read M4A file: %w", err)
|
return fmt.Errorf("failed to open M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
defer input.Close()
|
||||||
|
|
||||||
// Find moov atom position
|
info, err := input.Stat()
|
||||||
moovPos := findAtom(data, "moov", 0)
|
if err != nil {
|
||||||
if moovPos < 0 {
|
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")
|
return fmt.Errorf("moov atom not found in M4A file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find udta atom inside moov, or create one
|
moovContentStart := moovHeader.offset + moovHeader.headerSize
|
||||||
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
|
moovContentSize := moovHeader.size - moovHeader.headerSize
|
||||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
|
||||||
|
|
||||||
// Build new metadata atoms
|
udtaHeader, udtaFound, err := findAtomInRange(input, moovContentStart, moovContentSize, "udta", fileSize)
|
||||||
metaAtom := buildMetaAtom(metadata, coverData)
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to locate udta atom: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var newData []byte
|
var metaHeader atomHeader
|
||||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
metaFound := false
|
||||||
// udta exists, find meta inside it or replace
|
if udtaFound {
|
||||||
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
|
udtaContentStart := udtaHeader.offset + udtaHeader.headerSize
|
||||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
udtaContentSize := udtaHeader.size - udtaHeader.headerSize
|
||||||
|
metaHeader, metaFound, err = findAtomInRange(input, udtaContentStart, udtaContentSize, "meta", fileSize)
|
||||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
if err != nil {
|
||||||
// Replace existing meta atom
|
return fmt.Errorf("failed to locate meta atom: %w", err)
|
||||||
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
|
|
||||||
newData = append(newData, data[:metaPos]...)
|
|
||||||
newData = append(newData, metaAtom...)
|
|
||||||
newData = append(newData, data[metaPos+metaSize:]...)
|
|
||||||
} else {
|
|
||||||
// Add meta atom to udta
|
|
||||||
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
|
||||||
newUdtaSize := 8 + len(newUdtaContent)
|
|
||||||
newUdta := make([]byte, 4)
|
|
||||||
newUdta[0] = byte(newUdtaSize >> 24)
|
|
||||||
newUdta[1] = byte(newUdtaSize >> 16)
|
|
||||||
newUdta[2] = byte(newUdtaSize >> 8)
|
|
||||||
newUdta[3] = byte(newUdtaSize)
|
|
||||||
newUdta = append(newUdta, []byte("udta")...)
|
|
||||||
newUdta = append(newUdta, newUdtaContent...)
|
|
||||||
|
|
||||||
newData = append(newData, data[:udtaPos]...)
|
|
||||||
newData = append(newData, newUdta...)
|
|
||||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Create new udta with meta
|
|
||||||
udtaContent := metaAtom
|
|
||||||
udtaSize := 8 + len(udtaContent)
|
|
||||||
newUdta := make([]byte, 4)
|
|
||||||
newUdta[0] = byte(udtaSize >> 24)
|
|
||||||
newUdta[1] = byte(udtaSize >> 16)
|
|
||||||
newUdta[2] = byte(udtaSize >> 8)
|
|
||||||
newUdta[3] = byte(udtaSize)
|
|
||||||
newUdta = append(newUdta, []byte("udta")...)
|
|
||||||
newUdta = append(newUdta, udtaContent...)
|
|
||||||
|
|
||||||
// Insert udta at end of moov
|
|
||||||
insertPos := moovPos + moovSize
|
|
||||||
newData = append(newData, data[:insertPos]...)
|
|
||||||
newData = append(newData, newUdta...)
|
|
||||||
newData = append(newData, data[insertPos:]...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update moov size
|
metaAtom := buildMetaAtom(metadata, coverData)
|
||||||
newMoovSize := moovSize + len(newData) - len(data)
|
metaSize := int64(len(metaAtom))
|
||||||
newData[moovPos] = byte(newMoovSize >> 24)
|
|
||||||
newData[moovPos+1] = byte(newMoovSize >> 16)
|
|
||||||
newData[moovPos+2] = byte(newMoovSize >> 8)
|
|
||||||
newData[moovPos+3] = byte(newMoovSize)
|
|
||||||
|
|
||||||
// Write back to file
|
var delta int64
|
||||||
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
var newUdtaSize int64
|
||||||
return fmt.Errorf("failed to write M4A file: %w", err)
|
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")
|
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findAtom finds an atom by name starting from offset
|
|
||||||
func findAtom(data []byte, name string, offset int) int {
|
|
||||||
for i := offset; i < len(data)-8; {
|
|
||||||
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
|
||||||
if size < 8 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
atomName := string(data[i+4 : i+8])
|
|
||||||
if atomName == name {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
i += size
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||||
// Build ilst content
|
|
||||||
var ilst []byte
|
var ilst []byte
|
||||||
|
|
||||||
// ©nam - Title
|
|
||||||
if metadata.Title != "" {
|
if metadata.Title != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©ART - Artist
|
|
||||||
if metadata.Artist != "" {
|
if metadata.Artist != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©alb - Album
|
|
||||||
if metadata.Album != "" {
|
if metadata.Album != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// aART - Album Artist
|
|
||||||
if metadata.AlbumArtist != "" {
|
if metadata.AlbumArtist != "" {
|
||||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©day - Year/Date
|
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// trkn - Track Number
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk - Disc Number
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©lyr - Lyrics
|
|
||||||
if metadata.Lyrics != "" {
|
if metadata.Lyrics != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// covr - Cover Art
|
|
||||||
if len(coverData) > 0 {
|
if len(coverData) > 0 {
|
||||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build ilst atom
|
|
||||||
ilstSize := 8 + len(ilst)
|
ilstSize := 8 + len(ilst)
|
||||||
ilstAtom := make([]byte, 4)
|
ilstAtom := make([]byte, 4)
|
||||||
ilstAtom[0] = byte(ilstSize >> 24)
|
ilstAtom[0] = byte(ilstSize >> 24)
|
||||||
@@ -643,7 +731,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||||
ilstAtom = append(ilstAtom, ilst...)
|
ilstAtom = append(ilstAtom, ilst...)
|
||||||
|
|
||||||
// Build hdlr atom (required for meta)
|
|
||||||
hdlr := []byte{
|
hdlr := []byte{
|
||||||
0, 0, 0, 33, // size = 33
|
0, 0, 0, 33, // size = 33
|
||||||
'h', 'd', 'l', 'r',
|
'h', 'd', 'l', 'r',
|
||||||
@@ -656,7 +743,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
0, // null terminator
|
0, // null terminator
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build meta atom
|
|
||||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||||
metaContent = append(metaContent, ilstAtom...)
|
metaContent = append(metaContent, ilstAtom...)
|
||||||
|
|
||||||
@@ -672,11 +758,9 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
return metaAtom
|
return metaAtom
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
|
||||||
func buildTextAtom(name, value string) []byte {
|
func buildTextAtom(name, value string) []byte {
|
||||||
valueBytes := []byte(value)
|
valueBytes := []byte(value)
|
||||||
|
|
||||||
// data atom
|
|
||||||
dataSize := 16 + len(valueBytes)
|
dataSize := 16 + len(valueBytes)
|
||||||
dataAtom := make([]byte, 4)
|
dataAtom := make([]byte, 4)
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
@@ -688,7 +772,6 @@ func buildTextAtom(name, value string) []byte {
|
|||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
dataAtom = append(dataAtom, valueBytes...)
|
dataAtom = append(dataAtom, valueBytes...)
|
||||||
|
|
||||||
// container atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -703,7 +786,6 @@ func buildTextAtom(name, value string) []byte {
|
|||||||
|
|
||||||
// buildTrackNumberAtom builds trkn atom
|
// buildTrackNumberAtom builds trkn atom
|
||||||
func buildTrackNumberAtom(track, total int) []byte {
|
func buildTrackNumberAtom(track, total int) []byte {
|
||||||
// data atom with track number
|
|
||||||
dataAtom := []byte{
|
dataAtom := []byte{
|
||||||
0, 0, 0, 24, // size
|
0, 0, 0, 24, // size
|
||||||
'd', 'a', 't', 'a',
|
'd', 'a', 't', 'a',
|
||||||
@@ -715,7 +797,6 @@ func buildTrackNumberAtom(track, total int) []byte {
|
|||||||
0, 0, // padding
|
0, 0, // padding
|
||||||
}
|
}
|
||||||
|
|
||||||
// trkn atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -728,9 +809,7 @@ func buildTrackNumberAtom(track, total int) []byte {
|
|||||||
return atom
|
return atom
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDiscNumberAtom builds disk atom
|
|
||||||
func buildDiscNumberAtom(disc, total int) []byte {
|
func buildDiscNumberAtom(disc, total int) []byte {
|
||||||
// data atom with disc number
|
|
||||||
dataAtom := []byte{
|
dataAtom := []byte{
|
||||||
0, 0, 0, 22, // size
|
0, 0, 0, 22, // size
|
||||||
'd', 'a', 't', 'a',
|
'd', 'a', 't', 'a',
|
||||||
@@ -741,7 +820,6 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
|||||||
byte(total >> 8), byte(total), // total discs
|
byte(total >> 8), byte(total), // total discs
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -756,13 +834,11 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
|||||||
|
|
||||||
// buildCoverAtom builds covr atom with image data
|
// buildCoverAtom builds covr atom with image data
|
||||||
func buildCoverAtom(coverData []byte) []byte {
|
func buildCoverAtom(coverData []byte) []byte {
|
||||||
// Detect image type (JPEG = 13, PNG = 14)
|
imageType := byte(13)
|
||||||
imageType := byte(13) // default JPEG
|
|
||||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||||
imageType = 14 // PNG
|
imageType = 14
|
||||||
}
|
}
|
||||||
|
|
||||||
// data atom
|
|
||||||
dataSize := 16 + len(coverData)
|
dataSize := 16 + len(coverData)
|
||||||
dataAtom := make([]byte, 4)
|
dataAtom := make([]byte, 4)
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
@@ -770,11 +846,10 @@ func buildCoverAtom(coverData []byte) []byte {
|
|||||||
dataAtom[2] = byte(dataSize >> 8)
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
dataAtom[3] = byte(dataSize)
|
dataAtom[3] = byte(dataSize)
|
||||||
dataAtom = append(dataAtom, []byte("data")...)
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
dataAtom = append(dataAtom, 0, 0, 0, imageType)
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
dataAtom = append(dataAtom, 0, 0, 0, 0)
|
||||||
dataAtom = append(dataAtom, coverData...)
|
dataAtom = append(dataAtom, coverData...)
|
||||||
|
|
||||||
// covr atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -787,36 +862,226 @@ func buildCoverAtom(coverData []byte) []byte {
|
|||||||
return atom
|
return atom
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetM4AQuality reads audio quality from M4A file
|
|
||||||
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
data, err := os.ReadFile(filePath)
|
f, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
info, err := f.Stat()
|
||||||
moovPos := findAtom(data, "moov", 0)
|
if err != nil {
|
||||||
if moovPos < 0 {
|
return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err)
|
||||||
|
}
|
||||||
|
fileSize := info.Size()
|
||||||
|
|
||||||
|
moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err)
|
||||||
|
}
|
||||||
|
if !moovFound {
|
||||||
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for mp4a or alac atom which contains audio info
|
moovStart := moovHeader.offset
|
||||||
// This is a simplified search - real implementation would traverse the atom tree
|
moovEnd := moovHeader.offset + moovHeader.size
|
||||||
for i := moovPos; i < len(data)-20; i++ {
|
|
||||||
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
|
||||||
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
if err != nil {
|
||||||
if i+24 < len(data) {
|
return AudioQuality{}, err
|
||||||
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
|
||||||
// For AAC, bit depth is typically 16
|
|
||||||
bitDepth := 16
|
|
||||||
if string(data[i:i+4]) == "alac" {
|
|
||||||
// ALAC can have higher bit depth, check esds or alac specific data
|
|
||||||
bitDepth = 24 // Assume 24-bit for ALAC
|
|
||||||
}
|
|
||||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
|
buf := make([]byte, 24)
|
||||||
|
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleRate := int(buf[22])<<8 | int(buf[23])
|
||||||
|
bitDepth := 16
|
||||||
|
if atomType == "alac" {
|
||||||
|
bitDepth = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type atomHeader struct {
|
||||||
|
offset int64
|
||||||
|
size int64
|
||||||
|
headerSize int64
|
||||||
|
typ string
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) {
|
||||||
|
if offset+8 > fileSize {
|
||||||
|
return atomHeader{}, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
headerBuf := make([]byte, 8)
|
||||||
|
if _, err := f.ReadAt(headerBuf, offset); err != nil {
|
||||||
|
return atomHeader{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
size32 := binary.BigEndian.Uint32(headerBuf[0:4])
|
||||||
|
typ := string(headerBuf[4:8])
|
||||||
|
|
||||||
|
if size32 == 1 {
|
||||||
|
if offset+16 > fileSize {
|
||||||
|
return atomHeader{}, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
extBuf := make([]byte, 8)
|
||||||
|
if _, err := f.ReadAt(extBuf, offset+8); err != nil {
|
||||||
|
return atomHeader{}, err
|
||||||
|
}
|
||||||
|
size64 := binary.BigEndian.Uint64(extBuf)
|
||||||
|
return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) {
|
||||||
|
if size <= 0 {
|
||||||
|
return atomHeader{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
end := start + size
|
||||||
|
pos := start
|
||||||
|
|
||||||
|
for pos+8 <= end {
|
||||||
|
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||||
|
if err != nil {
|
||||||
|
return atomHeader{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
atomSize := header.size
|
||||||
|
if atomSize == 0 {
|
||||||
|
atomSize = end - pos
|
||||||
|
}
|
||||||
|
|
||||||
|
if atomSize < header.headerSize {
|
||||||
|
return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ)
|
||||||
|
}
|
||||||
|
|
||||||
|
header.size = atomSize
|
||||||
|
if header.typ == target {
|
||||||
|
return header, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += atomSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return atomHeader{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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) {
|
||||||
|
const chunkSize = 64 * 1024
|
||||||
|
patternMP4A := []byte("mp4a")
|
||||||
|
patternALAC := []byte("alac")
|
||||||
|
|
||||||
|
var tail []byte
|
||||||
|
readPos := start
|
||||||
|
|
||||||
|
for readPos < end {
|
||||||
|
toRead := end - readPos
|
||||||
|
if toRead > chunkSize {
|
||||||
|
toRead = chunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, toRead)
|
||||||
|
n, err := f.ReadAt(buf, readPos)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
data := append(tail, buf[:n]...)
|
||||||
|
mp4aIdx := bytes.Index(data, patternMP4A)
|
||||||
|
alacIdx := bytes.Index(data, patternALAC)
|
||||||
|
|
||||||
|
bestIdx := -1
|
||||||
|
bestType := ""
|
||||||
|
switch {
|
||||||
|
case mp4aIdx >= 0 && alacIdx >= 0:
|
||||||
|
if mp4aIdx <= alacIdx {
|
||||||
|
bestIdx = mp4aIdx
|
||||||
|
bestType = "mp4a"
|
||||||
|
} else {
|
||||||
|
bestIdx = alacIdx
|
||||||
|
bestType = "alac"
|
||||||
|
}
|
||||||
|
case mp4aIdx >= 0:
|
||||||
|
bestIdx = mp4aIdx
|
||||||
|
bestType = "mp4a"
|
||||||
|
case alacIdx >= 0:
|
||||||
|
bestIdx = alacIdx
|
||||||
|
bestType = "alac"
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestIdx >= 0 {
|
||||||
|
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
||||||
|
if absolute+24 > fileSize {
|
||||||
|
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||||
|
}
|
||||||
|
return absolute, bestType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) >= 3 {
|
||||||
|
tail = append([]byte{}, data[len(data)-3:]...)
|
||||||
|
} else {
|
||||||
|
tail = append([]byte{}, data...)
|
||||||
|
}
|
||||||
|
|
||||||
|
readPos += int64(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// mobile_deps.go
|
||||||
|
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
||||||
|
// These packages are required by gomobile bind but not directly imported in code.
|
||||||
|
|
||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
// Required for gomobile bind to work
|
||||||
|
_ "golang.org/x/mobile/bind"
|
||||||
|
)
|
||||||
@@ -6,11 +6,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// ISRC to Track ID Cache
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// TrackIDCacheEntry holds cached track ID with metadata
|
|
||||||
type TrackIDCacheEntry struct {
|
type TrackIDCacheEntry struct {
|
||||||
TidalTrackID int64
|
TidalTrackID int64
|
||||||
QobuzTrackID int64
|
QobuzTrackID int64
|
||||||
@@ -18,11 +13,13 @@ type TrackIDCacheEntry struct {
|
|||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackIDCache caches ISRC to track ID mappings
|
|
||||||
type TrackIDCache struct {
|
type TrackIDCache struct {
|
||||||
cache map[string]*TrackIDCacheEntry
|
cache map[string]*TrackIDCacheEntry
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
|
// Cleanup is triggered on writes at a fixed interval to avoid unbounded growth.
|
||||||
|
lastCleanup time.Time
|
||||||
|
cleanupInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -30,30 +27,49 @@ var (
|
|||||||
trackIDCacheOnce sync.Once
|
trackIDCacheOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetTrackIDCache returns the global track ID cache
|
|
||||||
func GetTrackIDCache() *TrackIDCache {
|
func GetTrackIDCache() *TrackIDCache {
|
||||||
trackIDCacheOnce.Do(func() {
|
trackIDCacheOnce.Do(func() {
|
||||||
globalTrackIDCache = &TrackIDCache{
|
globalTrackIDCache = &TrackIDCache{
|
||||||
cache: make(map[string]*TrackIDCacheEntry),
|
cache: make(map[string]*TrackIDCacheEntry),
|
||||||
ttl: 30 * time.Minute, // Cache for 30 minutes
|
ttl: 30 * time.Minute,
|
||||||
|
cleanupInterval: 5 * time.Minute,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalTrackIDCache
|
return globalTrackIDCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a cached entry by ISRC
|
|
||||||
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
entry, exists := c.cache[isrc]
|
entry, exists := c.cache[isrc]
|
||||||
if !exists || time.Now().After(entry.ExpiresAt) {
|
if !exists {
|
||||||
|
c.mu.RUnlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return entry
|
expired := time.Now().After(entry.ExpiresAt)
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if !expired {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily delete expired entry.
|
||||||
|
c.mu.Lock()
|
||||||
|
entry, exists = c.cache[isrc]
|
||||||
|
if exists && time.Now().After(entry.ExpiresAt) {
|
||||||
|
delete(c.cache, isrc)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
|
||||||
|
for key, entry := range c.cache {
|
||||||
|
if now.After(entry.ExpiresAt) {
|
||||||
|
delete(c.cache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTidal caches Tidal track ID for an ISRC
|
|
||||||
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -64,10 +80,15 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
|||||||
c.cache[isrc] = entry
|
c.cache[isrc] = entry
|
||||||
}
|
}
|
||||||
entry.TidalTrackID = trackID
|
entry.TidalTrackID = trackID
|
||||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
now := time.Now()
|
||||||
|
entry.ExpiresAt = now.Add(c.ttl)
|
||||||
|
|
||||||
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||||
|
c.pruneExpiredLocked(now)
|
||||||
|
c.lastCleanup = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetQobuz caches Qobuz track ID for an ISRC
|
|
||||||
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -78,10 +99,15 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
|||||||
c.cache[isrc] = entry
|
c.cache[isrc] = entry
|
||||||
}
|
}
|
||||||
entry.QobuzTrackID = trackID
|
entry.QobuzTrackID = trackID
|
||||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
now := time.Now()
|
||||||
|
entry.ExpiresAt = now.Add(c.ttl)
|
||||||
|
|
||||||
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||||
|
c.pruneExpiredLocked(now)
|
||||||
|
c.lastCleanup = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAmazon caches Amazon track ID for an ISRC
|
|
||||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
@@ -92,27 +118,27 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
|||||||
c.cache[isrc] = entry
|
c.cache[isrc] = entry
|
||||||
}
|
}
|
||||||
entry.AmazonTrackID = trackID
|
entry.AmazonTrackID = 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear removes all cached entries
|
|
||||||
func (c *TrackIDCache) Clear() {
|
func (c *TrackIDCache) Clear() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.cache = make(map[string]*TrackIDCacheEntry)
|
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size returns the number of cached entries
|
|
||||||
func (c *TrackIDCache) Size() int {
|
func (c *TrackIDCache) Size() int {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
return len(c.cache)
|
return len(c.cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Parallel Download Helper
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// ParallelDownloadResult holds results from parallel operations
|
// ParallelDownloadResult holds results from parallel operations
|
||||||
type ParallelDownloadResult struct {
|
type ParallelDownloadResult struct {
|
||||||
CoverData []byte
|
CoverData []byte
|
||||||
@@ -122,8 +148,6 @@ type ParallelDownloadResult struct {
|
|||||||
LyricsErr error
|
LyricsErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
|
||||||
// This runs while the main audio download is happening
|
|
||||||
func FetchCoverAndLyricsParallel(
|
func FetchCoverAndLyricsParallel(
|
||||||
coverURL string,
|
coverURL string,
|
||||||
maxQualityCover bool,
|
maxQualityCover bool,
|
||||||
@@ -131,11 +155,11 @@ func FetchCoverAndLyricsParallel(
|
|||||||
trackName string,
|
trackName string,
|
||||||
artistName string,
|
artistName string,
|
||||||
embedLyrics bool,
|
embedLyrics bool,
|
||||||
|
durationMs int64,
|
||||||
) *ParallelDownloadResult {
|
) *ParallelDownloadResult {
|
||||||
result := &ParallelDownloadResult{}
|
result := &ParallelDownloadResult{}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
// Download cover in parallel
|
|
||||||
if coverURL != "" {
|
if coverURL != "" {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -152,20 +176,19 @@ func FetchCoverAndLyricsParallel(
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch lyrics in parallel
|
|
||||||
if embedLyrics {
|
if embedLyrics {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Println("[Parallel] Starting lyrics fetch...")
|
fmt.Println("[Parallel] Starting lyrics fetch...")
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.LyricsErr = err
|
result.LyricsErr = err
|
||||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
result.LyricsData = lyrics
|
result.LyricsData = lyrics
|
||||||
// Use LRC with metadata headers (like PC version)
|
|
||||||
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||||
} else {
|
} else {
|
||||||
@@ -179,11 +202,6 @@ func FetchCoverAndLyricsParallel(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Pre-warm Cache for Album/Playlist
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// PreWarmCacheRequest represents a track to pre-warm cache for
|
|
||||||
type PreWarmCacheRequest struct {
|
type PreWarmCacheRequest struct {
|
||||||
ISRC string
|
ISRC string
|
||||||
TrackName string
|
TrackName string
|
||||||
@@ -192,8 +210,6 @@ type PreWarmCacheRequest struct {
|
|||||||
Service string // "tidal", "qobuz", "amazon"
|
Service string // "tidal", "qobuz", "amazon"
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
|
|
||||||
// This runs in background while user is viewing the track list
|
|
||||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||||
if len(requests) == 0 {
|
if len(requests) == 0 {
|
||||||
return
|
return
|
||||||
@@ -202,12 +218,10 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
||||||
cache := GetTrackIDCache()
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
// Limit concurrent pre-warm requests
|
semaphore := make(chan struct{}, 3)
|
||||||
semaphore := make(chan struct{}, 3) // Max 3 concurrent
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, req := range requests {
|
for _, req := range requests {
|
||||||
// Skip if already cached
|
|
||||||
if cached := cache.Get(req.ISRC); cached != nil {
|
if cached := cache.Get(req.ISRC); cached != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -215,8 +229,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(r PreWarmCacheRequest) {
|
go func(r PreWarmCacheRequest) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
semaphore <- struct{}{} // Acquire
|
semaphore <- struct{}{}
|
||||||
defer func() { <-semaphore }() // Release
|
defer func() { <-semaphore }()
|
||||||
|
|
||||||
switch r.Service {
|
switch r.Service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
@@ -252,38 +266,26 @@ func preWarmQobuzCache(isrc string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||||
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
|
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
if err == nil && availability != nil && availability.Amazon {
|
if err == nil && availability != nil && availability.Amazon {
|
||||||
// Store Amazon URL in cache (using ISRC as key)
|
|
||||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||||
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Exported Functions for Flutter
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
|
|
||||||
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
|
||||||
func PreWarmCache(tracksJSON string) error {
|
func PreWarmCache(tracksJSON string) error {
|
||||||
var requests []PreWarmCacheRequest
|
var requests []PreWarmCacheRequest
|
||||||
// Parse JSON (simplified - in production use proper JSON parsing)
|
|
||||||
// For now, this is called from exports.go with proper parsing
|
|
||||||
|
|
||||||
go PreWarmTrackCache(requests) // Run in background
|
go PreWarmTrackCache(requests)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearTrackCache clears the track ID cache
|
|
||||||
func ClearTrackCache() {
|
func ClearTrackCache() {
|
||||||
GetTrackIDCache().Clear()
|
GetTrackIDCache().Clear()
|
||||||
fmt.Println("[Cache] Track ID cache cleared")
|
fmt.Println("[Cache] Track ID cache cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCacheSize returns the current cache size
|
|
||||||
func GetCacheSize() int {
|
func GetCacheSize() int {
|
||||||
return GetTrackIDCache().Size()
|
return GetTrackIDCache().Size()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress
|
|
||||||
// Now unified - returns data from multi-progress system
|
|
||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
CurrentFile string `json:"current_file"`
|
CurrentFile string `json:"current_file"`
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
@@ -15,21 +13,19 @@ type DownloadProgress struct {
|
|||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ItemProgress represents progress for a single download item
|
|
||||||
type ItemProgress struct {
|
type ItemProgress struct {
|
||||||
ItemID string `json:"item_id"`
|
ItemID string `json:"item_id"`
|
||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
Progress float64 `json:"progress"`
|
||||||
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
SpeedMBps float64 `json:"speed_mbps"`
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiProgress holds progress for multiple concurrent downloads
|
|
||||||
type MultiProgress struct {
|
type MultiProgress struct {
|
||||||
Items map[string]*ItemProgress `json:"items"`
|
Items map[string]*ItemProgress `json:"items"`
|
||||||
}
|
}
|
||||||
@@ -38,22 +34,18 @@ var (
|
|||||||
downloadDir string
|
downloadDir string
|
||||||
downloadDirMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
|
|
||||||
// Multi-download progress tracking (unified system)
|
|
||||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
multiMu sync.RWMutex
|
multiMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// getProgress returns current download progress from multi-progress system
|
|
||||||
// Returns first active item's progress for backward compatibility
|
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
|
|
||||||
// Find first active item
|
|
||||||
for _, item := range multiProgress.Items {
|
for _, item := range multiProgress.Items {
|
||||||
return DownloadProgress{
|
return DownloadProgress{
|
||||||
CurrentFile: item.ItemID,
|
CurrentFile: item.ItemID,
|
||||||
Progress: item.Progress * 100, // Convert to percentage
|
Progress: item.Progress * 100,
|
||||||
BytesTotal: item.BytesTotal,
|
BytesTotal: item.BytesTotal,
|
||||||
BytesReceived: item.BytesReceived,
|
BytesReceived: item.BytesReceived,
|
||||||
IsDownloading: item.IsDownloading,
|
IsDownloading: item.IsDownloading,
|
||||||
@@ -64,7 +56,6 @@ func getProgress() DownloadProgress {
|
|||||||
return DownloadProgress{}
|
return DownloadProgress{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMultiProgress returns progress for all active downloads as JSON
|
|
||||||
func GetMultiProgress() string {
|
func GetMultiProgress() string {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
@@ -76,7 +67,6 @@ func GetMultiProgress() string {
|
|||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetItemProgress returns progress for a specific item as JSON
|
|
||||||
func GetItemProgress(itemID string) string {
|
func GetItemProgress(itemID string) string {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
@@ -203,14 +193,6 @@ func setDownloadDir(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadDir returns the default download directory
|
|
||||||
// Kept for potential future use
|
|
||||||
// func getDownloadDir() string {
|
|
||||||
// downloadDirMu.RLock()
|
|
||||||
// defer downloadDirMu.RUnlock()
|
|
||||||
// return downloadDir
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
// 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) }
|
||||||
@@ -249,10 +231,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
|
|
||||||
// Update progress when we've received at least 64KB since last update
|
|
||||||
// Also update on first write to show download has started
|
|
||||||
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
// Calculate speed (MB/s) based on bytes received since last update
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
elapsed := now.Sub(pw.lastTime).Seconds()
|
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||||
var speedMBps float64
|
var speedMBps float64
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
|
||||||
type QobuzDownloader struct {
|
type QobuzDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
appID string
|
appID string
|
||||||
@@ -25,12 +24,10 @@ type QobuzDownloader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Qobuz downloader instance for connection reuse
|
|
||||||
globalQobuzDownloader *QobuzDownloader
|
globalQobuzDownloader *QobuzDownloader
|
||||||
qobuzDownloaderOnce sync.Once
|
qobuzDownloaderOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzTrack represents a Qobuz track
|
|
||||||
type QobuzTrack struct {
|
type QobuzTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -51,7 +48,6 @@ type QobuzTrack struct {
|
|||||||
} `json:"performer"`
|
} `json:"performer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzArtistsMatch checks if the artist names are similar enough
|
|
||||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
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))
|
||||||
@@ -66,22 +62,17 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split expected artists by common separators (comma, feat, ft., &, and)
|
|
||||||
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
|
||||||
expectedArtists := qobuzSplitArtists(normExpected)
|
expectedArtists := qobuzSplitArtists(normExpected)
|
||||||
foundArtists := qobuzSplitArtists(normFound)
|
foundArtists := qobuzSplitArtists(normFound)
|
||||||
|
|
||||||
// Check if ANY expected artist matches ANY found artist
|
|
||||||
for _, exp := range expectedArtists {
|
for _, exp := range expectedArtists {
|
||||||
for _, fnd := range foundArtists {
|
for _, fnd := range foundArtists {
|
||||||
if exp == fnd {
|
if exp == fnd {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Also check contains for partial matches
|
|
||||||
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Check same words different order
|
|
||||||
if qobuzSameWordsUnordered(exp, fnd) {
|
if qobuzSameWordsUnordered(exp, fnd) {
|
||||||
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
||||||
return true
|
return true
|
||||||
@@ -89,8 +80,6 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(expectedArtist)
|
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
||||||
foundLatin := qobuzIsLatinScript(foundArtist)
|
foundLatin := qobuzIsLatinScript(foundArtist)
|
||||||
if expectedLatin != foundLatin {
|
if expectedLatin != foundLatin {
|
||||||
@@ -101,9 +90,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzSplitArtists splits artist string by common separators
|
|
||||||
func qobuzSplitArtists(artists string) []string {
|
func qobuzSplitArtists(artists string) []string {
|
||||||
// Replace common separators with a standard one
|
|
||||||
normalized := artists
|
normalized := artists
|
||||||
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
||||||
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
||||||
@@ -162,7 +149,6 @@ func qobuzSameWordsUnordered(a, b string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzTitlesMatch checks if track titles are similar enough
|
|
||||||
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
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))
|
||||||
@@ -172,12 +158,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
|
|
||||||
cleanExpected := qobuzCleanTitle(normExpected)
|
cleanExpected := qobuzCleanTitle(normExpected)
|
||||||
cleanFound := qobuzCleanTitle(normFound)
|
cleanFound := qobuzCleanTitle(normFound)
|
||||||
|
|
||||||
@@ -185,14 +169,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if cleaned versions contain each other
|
|
||||||
if cleanExpected != "" && cleanFound != "" {
|
if cleanExpected != "" && cleanFound != "" {
|
||||||
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract core title (before any parentheses/brackets)
|
|
||||||
coreExpected := qobuzExtractCoreTitle(normExpected)
|
coreExpected := qobuzExtractCoreTitle(normExpected)
|
||||||
coreFound := qobuzExtractCoreTitle(normFound)
|
coreFound := qobuzExtractCoreTitle(normFound)
|
||||||
|
|
||||||
@@ -233,19 +215,15 @@ func qobuzExtractCoreTitle(title string) string {
|
|||||||
return strings.TrimSpace(title[:cutIdx])
|
return strings.TrimSpace(title[:cutIdx])
|
||||||
}
|
}
|
||||||
|
|
||||||
// qobuzCleanTitle removes common suffixes from track titles for comparison
|
|
||||||
func qobuzCleanTitle(title string) string {
|
func qobuzCleanTitle(title string) string {
|
||||||
cleaned := title
|
cleaned := title
|
||||||
|
|
||||||
// Remove content in parentheses/brackets that are version indicators
|
|
||||||
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
|
|
||||||
versionPatterns := []string{
|
versionPatterns := []string{
|
||||||
"remaster", "remastered", "deluxe", "bonus", "single",
|
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||||
"album version", "radio edit", "original mix", "extended",
|
"album version", "radio edit", "original mix", "extended",
|
||||||
"club mix", "remix", "live", "acoustic", "demo",
|
"club mix", "remix", "live", "acoustic", "demo",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove parenthetical content if it contains version indicators
|
|
||||||
for {
|
for {
|
||||||
startParen := strings.LastIndex(cleaned, "(")
|
startParen := strings.LastIndex(cleaned, "(")
|
||||||
endParen := strings.LastIndex(cleaned, ")")
|
endParen := strings.LastIndex(cleaned, ")")
|
||||||
@@ -266,7 +244,6 @@ func qobuzCleanTitle(title string) string {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same for brackets
|
|
||||||
for {
|
for {
|
||||||
startBracket := strings.LastIndex(cleaned, "[")
|
startBracket := strings.LastIndex(cleaned, "[")
|
||||||
endBracket := strings.LastIndex(cleaned, "]")
|
endBracket := strings.LastIndex(cleaned, "]")
|
||||||
@@ -287,7 +264,6 @@ func qobuzCleanTitle(title string) string {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove trailing " - version" patterns
|
|
||||||
dashPatterns := []string{
|
dashPatterns := []string{
|
||||||
" - remaster", " - remastered", " - single version", " - radio edit",
|
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||||
" - live", " - acoustic", " - demo", " - remix",
|
" - live", " - acoustic", " - demo", " - remix",
|
||||||
@@ -298,7 +274,6 @@ func qobuzCleanTitle(title string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove multiple spaces
|
|
||||||
for strings.Contains(cleaned, " ") {
|
for strings.Contains(cleaned, " ") {
|
||||||
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||||
}
|
}
|
||||||
@@ -358,7 +333,6 @@ func containsQueryQobuz(queries []string, query string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
qobuzDownloaderOnce.Do(func() {
|
qobuzDownloaderOnce.Do(func() {
|
||||||
globalQobuzDownloader = &QobuzDownloader{
|
globalQobuzDownloader = &QobuzDownloader{
|
||||||
@@ -369,7 +343,6 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
return globalQobuzDownloader
|
return globalQobuzDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrackByID fetches track info directly by Qobuz track ID
|
|
||||||
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||||
// Qobuz API: /track/get?track_id=XXX
|
// Qobuz API: /track/get?track_id=XXX
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
||||||
@@ -402,10 +375,11 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
|||||||
// Uses same APIs as PC version for compatibility
|
// 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)
|
// Same APIs as PC version (referensi/backend/qobuz.go)
|
||||||
// Primary: dab.yeet.su, Fallback: dabmusic.xyz
|
// Primary: dab.yeet.su, Fallback: dabmusic.xyz, qobuz.squid.wtf
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId=
|
||||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
|
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId=
|
||||||
|
"cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", // qobuz.squid.wtf/api/download-music?track_id=
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -420,7 +394,95 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
|||||||
return apis
|
return apis
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackByISRC searches for a track by ISRC
|
// mapJumoQuality maps Qobuz quality codes to Jumo format
|
||||||
|
func mapJumoQuality(quality string) int {
|
||||||
|
switch quality {
|
||||||
|
case "6":
|
||||||
|
return 6 // 16-bit FLAC
|
||||||
|
case "7":
|
||||||
|
return 7 // 24-bit 96kHz
|
||||||
|
case "27":
|
||||||
|
return 27 // 24-bit 192kHz
|
||||||
|
default:
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeXOR decodes XOR-encoded response from Jumo API
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFromJumo gets download URL from Jumo API (fallback)
|
||||||
|
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
|
||||||
|
formatID := mapJumoQuality(quality)
|
||||||
|
region := "US"
|
||||||
|
|
||||||
|
// Jumo API endpoint
|
||||||
|
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Try parsing as plain JSON first
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
// Try XOR decoding
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for URL in various response formats
|
||||||
|
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)
|
||||||
@@ -463,7 +525,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
|
||||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
// 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)
|
||||||
@@ -508,7 +569,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||||
|
|
||||||
if len(isrcMatches) > 0 {
|
if len(isrcMatches) > 0 {
|
||||||
// Verify duration if provided
|
|
||||||
if expectedDurationSec > 0 {
|
if expectedDurationSec > 0 {
|
||||||
var durationVerifiedMatches []*QobuzTrack
|
var durationVerifiedMatches []*QobuzTrack
|
||||||
for _, track := range isrcMatches {
|
for _, track := range isrcMatches {
|
||||||
@@ -516,7 +576,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
// Allow 10 seconds tolerance
|
|
||||||
if durationDiff <= 10 {
|
if durationDiff <= 10 {
|
||||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
}
|
}
|
||||||
@@ -528,14 +587,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
return durationVerifiedMatches[0], nil
|
return durationVerifiedMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC matches but duration doesn't
|
|
||||||
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||||
expectedDurationSec, isrcMatches[0].Duration)
|
expectedDurationSec, isrcMatches[0].Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration to verify, return first match
|
|
||||||
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
return isrcMatches[0], nil
|
return isrcMatches[0], nil
|
||||||
}
|
}
|
||||||
@@ -547,17 +604,14 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
|
|
||||||
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
|
||||||
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
||||||
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
|
||||||
// Now includes romaji conversion for Japanese text (same as Tidal)
|
// Now includes romaji conversion for Japanese text (same as Tidal)
|
||||||
// Also includes title verification to prevent wrong song downloads
|
// 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) {
|
||||||
@@ -696,34 +750,32 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(durationMatches) > 0 {
|
if len(durationMatches) > 0 {
|
||||||
// Return best quality among duration matches
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration match found
|
|
||||||
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
|
// 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
|
||||||
}
|
}
|
||||||
@@ -739,8 +791,6 @@ type qobuzAPIResult struct {
|
|||||||
duration time.Duration
|
duration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
|
|
||||||
// "Siapa cepat dia dapat" - first successful response wins
|
|
||||||
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")
|
||||||
@@ -756,9 +806,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
|||||||
go func(api string) {
|
go func(api string) {
|
||||||
reqStart := time.Now()
|
reqStart := time.Now()
|
||||||
|
|
||||||
client := &http.Client{
|
client := NewHTTPClientWithTimeout(15 * time.Second)
|
||||||
Timeout: 15 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||||
|
|
||||||
@@ -824,7 +872,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality 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
|
// Drain remaining results to avoid goroutine leaks
|
||||||
go func(remaining int) {
|
go func(remaining int) {
|
||||||
@@ -847,21 +895,42 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
|||||||
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
|
||||||
// "Siapa cepat dia dapat" - first successful response wins
|
|
||||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||||
apis := q.GetAvailableAPIs()
|
apis := q.GetAvailableAPIs()
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
return "", fmt.Errorf("no Qobuz API available")
|
return "", fmt.Errorf("no Qobuz API available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use parallel approach - request from all APIs simultaneously
|
|
||||||
_, 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
|
// All standard APIs failed, try Jumo as fallback
|
||||||
|
GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n")
|
||||||
|
jumoURL, jumoErr := q.downloadFromJumo(trackID, quality)
|
||||||
|
if jumoErr == nil {
|
||||||
|
return jumoURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If quality is 27 (hi-res), try fallback to lower quality
|
||||||
|
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
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
@@ -899,7 +968,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -909,16 +977,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
written, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,7 +991,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
// Check for any errors
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
if isDownloadCancelled(itemID) {
|
if isDownloadCancelled(itemID) {
|
||||||
@@ -952,7 +1016,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QobuzDownloadResult contains download result with quality info
|
|
||||||
type QobuzDownloadResult struct {
|
type QobuzDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
@@ -966,22 +1029,18 @@ type QobuzDownloadResult struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromQobuz downloads a track using the request parameters
|
|
||||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert expected duration from ms to seconds
|
|
||||||
expectedDurationSec := req.DurationMS / 1000
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority)
|
|
||||||
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
|
||||||
@@ -1052,7 +1111,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
|
||||||
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,
|
||||||
@@ -1064,7 +1122,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
filename = sanitizeFilename(filename) + ".flac"
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
outputPath := filepath.Join(req.OutputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
@@ -1083,12 +1140,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
// Get actual quality from track metadata
|
|
||||||
actualBitDepth := track.MaximumBitDepth
|
actualBitDepth := track.MaximumBitDepth
|
||||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||||
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
@@ -1106,6 +1161,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
req.TrackName,
|
req.TrackName,
|
||||||
req.ArtistName,
|
req.ArtistName,
|
||||||
req.EmbedLyrics,
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -1120,34 +1176,37 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using parallel-fetched cover data
|
|
||||||
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
|
|
||||||
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
|
|
||||||
albumName := track.Album.Title
|
albumName := track.Album.Title
|
||||||
if req.AlbumName != "" {
|
if req.AlbumName != "" {
|
||||||
albumName = req.AlbumName
|
albumName = req.AlbumName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use track number from request if available, otherwise from Qobuz API
|
||||||
|
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, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||||
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, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
|
Genre: req.Genre, // From Deezer album metadata
|
||||||
|
Label: req.Label, // From Deezer album metadata
|
||||||
|
Copyright: req.Copyright, // From Deezer album metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
@@ -1158,13 +1217,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed lyrics from parallel fetch
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
lyricsMode := req.LyricsMode
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
if lyricsMode == "" {
|
||||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
lyricsMode = "embed"
|
||||||
} else {
|
}
|
||||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
|
||||||
|
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 {
|
} else if req.EmbedLyrics {
|
||||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||||
@@ -1181,7 +1255,7 @@ 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, // Qobuz track struct limitations
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RateLimiter implements a sliding window rate limiter
|
|
||||||
type RateLimiter struct {
|
type RateLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
maxRequests int
|
maxRequests int
|
||||||
@@ -13,7 +12,6 @@ type RateLimiter struct {
|
|||||||
timestamps []time.Time
|
timestamps []time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRateLimiter creates a new rate limiter with specified max requests per window
|
|
||||||
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
||||||
return &RateLimiter{
|
return &RateLimiter{
|
||||||
maxRequests: maxRequests,
|
maxRequests: maxRequests,
|
||||||
@@ -22,39 +20,31 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForSlot blocks until a request is allowed under the rate limit
|
|
||||||
// Returns immediately if under the limit, otherwise waits until a slot is available
|
|
||||||
func (r *RateLimiter) WaitForSlot() {
|
func (r *RateLimiter) WaitForSlot() {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Remove timestamps outside the window
|
|
||||||
r.cleanOldTimestamps(now)
|
r.cleanOldTimestamps(now)
|
||||||
|
|
||||||
// If under limit, record and return immediately
|
|
||||||
if len(r.timestamps) < r.maxRequests {
|
if len(r.timestamps) < r.maxRequests {
|
||||||
r.timestamps = append(r.timestamps, now)
|
r.timestamps = append(r.timestamps, now)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate wait time until oldest timestamp expires
|
|
||||||
oldestTimestamp := r.timestamps[0]
|
oldestTimestamp := r.timestamps[0]
|
||||||
waitUntil := oldestTimestamp.Add(r.window)
|
waitUntil := oldestTimestamp.Add(r.window)
|
||||||
waitDuration := waitUntil.Sub(now)
|
waitDuration := waitUntil.Sub(now)
|
||||||
|
|
||||||
if waitDuration > 0 {
|
if waitDuration > 0 {
|
||||||
// Release lock while waiting
|
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
time.Sleep(waitDuration)
|
time.Sleep(waitDuration)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|
||||||
// Clean again after waiting
|
|
||||||
r.cleanOldTimestamps(time.Now())
|
r.cleanOldTimestamps(time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record this request
|
|
||||||
r.timestamps = append(r.timestamps, time.Now())
|
r.timestamps = append(r.timestamps, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TryAcquire attempts to acquire a slot without blocking
|
|
||||||
// Returns true if successful, false if rate limit would be exceeded
|
|
||||||
func (r *RateLimiter) TryAcquire() bool {
|
func (r *RateLimiter) TryAcquire() bool {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
@@ -93,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available returns the number of requests available in the current window
|
|
||||||
func (r *RateLimiter) Available() int {
|
func (r *RateLimiter) Available() int {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
@@ -105,7 +92,6 @@ func (r *RateLimiter) Available() int {
|
|||||||
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
|
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
|
||||||
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
|
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
|
||||||
|
|
||||||
// GetSongLinkRateLimiter returns the global SongLink rate limiter
|
|
||||||
func GetSongLinkRateLimiter() *RateLimiter {
|
func GetSongLinkRateLimiter() *RateLimiter {
|
||||||
return songLinkRateLimiter
|
return songLinkRateLimiter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Hiragana to Romaji mapping
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
var hiraganaToRomaji = map[rune]string{
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
|
|||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Katakana to Romaji mapping
|
|
||||||
var katakanaToRomaji = map[rune]string{
|
var katakanaToRomaji = map[rune]string{
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
|
|||||||
'ヴ': "vu",
|
'ヴ': "vu",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combination mappings for きゃ, しゃ, etc.
|
|
||||||
var combinationHiragana = map[string]string{
|
var combinationHiragana = map[string]string{
|
||||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
|
|||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainsJapanese checks if a string contains Japanese characters
|
|
||||||
func ContainsJapanese(s string) bool {
|
func ContainsJapanese(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||||
@@ -114,8 +110,6 @@ func isKanji(r rune) bool {
|
|||||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||||
}
|
}
|
||||||
|
|
||||||
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
|
||||||
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
|
||||||
func JapaneseToRomaji(text string) string {
|
func JapaneseToRomaji(text string) string {
|
||||||
if !ContainsJapanese(text) {
|
if !ContainsJapanese(text) {
|
||||||
return text
|
return text
|
||||||
@@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string {
|
|||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSearchQuery creates a search query from track name and artist
|
|
||||||
// Converts Japanese to romaji if present
|
|
||||||
func BuildSearchQuery(trackName, artistName string) string {
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
// Convert Japanese to romaji
|
// Convert Japanese to romaji
|
||||||
trackRomaji := JapaneseToRomaji(trackName)
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
@@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string {
|
|||||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanSearchQuery removes special characters that might interfere with search
|
|
||||||
func cleanSearchQuery(s string) string {
|
func cleanSearchQuery(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string {
|
|||||||
return strings.TrimSpace(result.String())
|
return strings.TrimSpace(result.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
|
||||||
// This is useful for creating search queries that work better with Tidal's search
|
|
||||||
func CleanToASCII(s string) string {
|
func CleanToASCII(s string) string {
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
|
|||||||
@@ -11,12 +11,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SongLinkClient handles song.link API interactions
|
|
||||||
type SongLinkClient struct {
|
type SongLinkClient struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackAvailability represents track availability on different platforms
|
|
||||||
type TrackAvailability struct {
|
type TrackAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
@@ -31,32 +29,49 @@ type TrackAvailability struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global SongLink client instance for connection reuse
|
|
||||||
globalSongLinkClient *SongLinkClient
|
globalSongLinkClient *SongLinkClient
|
||||||
songLinkClientOnce sync.Once
|
songLinkClientOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
songLinkClientOnce.Do(func() {
|
songLinkClientOnce.Do(func() {
|
||||||
globalSongLinkClient = &SongLinkClient{
|
globalSongLinkClient = &SongLinkClient{
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
client: NewHTTPClientWithTimeout(SongLinkTimeout),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalSongLinkClient
|
return globalSongLinkClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckTrackAvailability checks track availability on streaming platforms
|
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
// Validate Spotify ID format (should be 22 characters alphanumeric)
|
|
||||||
if spotifyTrackID == "" {
|
if spotifyTrackID == "" {
|
||||||
return nil, fmt.Errorf("spotify track ID is empty")
|
return nil, fmt.Errorf("spotify track ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter - blocks until request is allowed
|
// Try SongLink first
|
||||||
|
availability, err := s.checkTrackAvailabilitySongLink(spotifyTrackID)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to IDHS if SongLink fails
|
||||||
|
LogWarn("SongLink", "SongLink failed, trying IDHS fallback: %v", err)
|
||||||
|
idhsClient := NewIDHSClient()
|
||||||
|
availability, err = idhsClient.GetAvailabilityFromSpotify(spotifyTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
||||||
|
}
|
||||||
|
LogInfo("SongLink", "IDHS fallback successful for %s", spotifyTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Qobuz availability separately via ISRC
|
||||||
|
if isrc != "" {
|
||||||
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkTrackAvailabilitySongLink is the original SongLink implementation
|
||||||
|
func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||||
|
|
||||||
@@ -68,7 +83,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use retry logic with User-Agent
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := DefaultRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,7 +90,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||||
}
|
}
|
||||||
@@ -109,35 +122,25 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
SpotifyID: spotifyTrackID,
|
SpotifyID: spotifyTrackID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
|
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
|
|
||||||
if isrc != "" {
|
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
|
||||||
}
|
|
||||||
|
|
||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStreamingURLs gets streaming URLs for a Spotify track
|
|
||||||
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
||||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,12 +194,9 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
|
|
||||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||||
func extractDeezerIDFromURL(deezerURL string) string {
|
func extractDeezerIDFromURL(deezerURL string) string {
|
||||||
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
|
|
||||||
parts := strings.Split(deezerURL, "/")
|
parts := strings.Split(deezerURL, "/")
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
// Get the last part which should be the ID
|
|
||||||
lastPart := parts[len(parts)-1]
|
lastPart := parts[len(parts)-1]
|
||||||
// Remove any query parameters
|
|
||||||
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
||||||
lastPart = lastPart[:idx]
|
lastPart = lastPart[:idx]
|
||||||
}
|
}
|
||||||
@@ -205,17 +205,16 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
|
||||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Deezer || availability.DeezerID == "" {
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
return "", fmt.Errorf("track not found on Deezer")
|
return "", fmt.Errorf("track not found on Deezer")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +226,6 @@ type AlbumAvailability struct {
|
|||||||
DeezerID string `json:"deezer_id,omitempty"`
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
|
||||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
// Use global rate limiter
|
// Use global rate limiter
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
@@ -274,7 +272,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
|||||||
SpotifyID: spotifyAlbumID,
|
SpotifyID: spotifyAlbumID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
@@ -290,32 +287,42 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Deezer || availability.DeezerID == "" {
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
return "", fmt.Errorf("album not found on Deezer")
|
return "", fmt.Errorf("album not found on Deezer")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Deezer ID Support - Query SongLink using Deezer as source
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
|
||||||
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
||||||
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
if deezerTrackID == "" {
|
if deezerTrackID == "" {
|
||||||
return nil, fmt.Errorf("deezer track ID is empty")
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter
|
// Try SongLink first
|
||||||
|
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to IDHS if SongLink fails
|
||||||
|
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
|
||||||
|
idhsClient := NewIDHSClient()
|
||||||
|
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
||||||
|
}
|
||||||
|
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
||||||
|
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build Deezer URL
|
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
// Build API URL using Deezer URL as source
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||||
|
|
||||||
@@ -371,25 +378,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
DeezerID: deezerTrackID,
|
DeezerID: deezerTrackID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Spotify
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
// Extract Spotify ID from URL
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer URL
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
}
|
}
|
||||||
@@ -397,7 +399,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
|
||||||
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||||
// entityType: "song" or "album"
|
// entityType: "song" or "album"
|
||||||
// entityID: the ID on that platform
|
// entityID: the ID on that platform
|
||||||
@@ -405,7 +406,7 @@ 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
|
// Use global rate limiter
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
@@ -459,24 +460,20 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
|
|
||||||
availability := &TrackAvailability{}
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
// Check Spotify
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
@@ -488,10 +485,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
|
|
||||||
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
|
|
||||||
parts := strings.Split(spotifyURL, "/track/")
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
// Get the ID part and remove any query parameters
|
|
||||||
idPart := parts[1]
|
idPart := parts[1]
|
||||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
@@ -501,17 +496,16 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
|
||||||
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if availability.SpotifyID == "" {
|
if availability.SpotifyID == "" {
|
||||||
return "", fmt.Errorf("track not found on Spotify")
|
return "", fmt.Errorf("track not found on Spotify")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.SpotifyID, nil
|
return availability.SpotifyID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,24 +515,23 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Tidal || availability.TidalURL == "" {
|
if !availability.Tidal || availability.TidalURL == "" {
|
||||||
return "", fmt.Errorf("track not found on Tidal")
|
return "", fmt.Errorf("track not found on Tidal")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.TidalURL, nil
|
return availability.TidalURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
|
||||||
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Amazon || availability.AmazonURL == "" {
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
return "", fmt.Errorf("track not found on Amazon Music")
|
return "", fmt.Errorf("track not found on Amazon Music")
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability.AmazonURL, nil
|
return availability.AmazonURL, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const (
|
|||||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||||
|
|
||||||
// Cache TTL settings
|
|
||||||
artistCacheTTL = 10 * time.Minute
|
artistCacheTTL = 10 * time.Minute
|
||||||
searchCacheTTL = 5 * time.Minute
|
searchCacheTTL = 5 * time.Minute
|
||||||
albumCacheTTL = 10 * time.Minute
|
albumCacheTTL = 10 * time.Minute
|
||||||
@@ -32,7 +31,6 @@ const (
|
|||||||
|
|
||||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||||
|
|
||||||
// cacheEntry holds cached data with expiration
|
|
||||||
type cacheEntry struct {
|
type cacheEntry struct {
|
||||||
data interface{}
|
data interface{}
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
@@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool {
|
|||||||
return time.Now().After(e.expiresAt)
|
return time.Now().After(e.expiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpotifyMetadataClient handles Spotify API interactions
|
|
||||||
type SpotifyMetadataClient struct {
|
type SpotifyMetadataClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
clientID string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
cachedToken string
|
cachedToken string
|
||||||
tokenExpiresAt time.Time
|
tokenExpiresAt time.Time
|
||||||
tokenMu sync.Mutex // Protects token cache for concurrent access
|
tokenMu sync.Mutex
|
||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
rngMu sync.Mutex
|
rngMu sync.Mutex
|
||||||
userAgent string
|
userAgent string
|
||||||
|
|
||||||
// Caches to reduce API calls
|
artistCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry // key: artistID
|
searchCache map[string]*cacheEntry
|
||||||
searchCache map[string]*cacheEntry // key: query+type
|
albumCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry // key: albumID
|
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom credentials storage (set from Flutter)
|
|
||||||
var (
|
var (
|
||||||
customClientID string
|
customClientID string
|
||||||
customClientSecret string
|
customClientSecret string
|
||||||
@@ -79,17 +74,14 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
|||||||
customClientSecret = clientSecret
|
customClientSecret = clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasSpotifyCredentials checks if Spotify credentials are configured
|
|
||||||
func HasSpotifyCredentials() bool {
|
func HasSpotifyCredentials() bool {
|
||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
// Check custom credentials first
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check environment variables
|
|
||||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -102,12 +94,10 @@ func getCredentials() (string, string, error) {
|
|||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
// Check custom credentials first
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return customClientID, customClientSecret, nil
|
return customClientID, customClientSecret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check environment variables
|
|
||||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
|
||||||
@@ -115,14 +105,10 @@ func getCredentials() (string, string, error) {
|
|||||||
return clientID, clientSecret, nil
|
return clientID, clientSecret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// No credentials available
|
|
||||||
return "", "", ErrNoSpotifyCredentials
|
return "", "", ErrNoSpotifyCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
|
||||||
// Returns error if credentials are not configured
|
|
||||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||||
// Get credentials - will error if not configured
|
|
||||||
clientID, clientSecret, err := getCredentials()
|
clientID, clientSecret, err := getCredentials()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -131,7 +117,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), // Use shared transport for connection pooling
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
@@ -143,7 +129,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackMetadata represents track information
|
|
||||||
type TrackMetadata struct {
|
type TrackMetadata struct {
|
||||||
SpotifyID string `json:"spotify_id,omitempty"`
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
@@ -161,7 +146,6 @@ type TrackMetadata struct {
|
|||||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumTrackMetadata holds per-track info for album/playlist
|
|
||||||
type AlbumTrackMetadata struct {
|
type AlbumTrackMetadata struct {
|
||||||
SpotifyID string `json:"spotify_id,omitempty"`
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
@@ -178,25 +162,26 @@ type AlbumTrackMetadata struct {
|
|||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
AlbumID string `json:"album_id,omitempty"`
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
AlbumURL string `json:"album_url,omitempty"`
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumInfoMetadata holds album information
|
|
||||||
type AlbumInfoMetadata struct {
|
type AlbumInfoMetadata struct {
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
|
ArtistId string `json:"artist_id,omitempty"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
|
Genre string `json:"genre,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Copyright string `json:"copyright,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumResponsePayload is the response for album requests
|
|
||||||
type AlbumResponsePayload struct {
|
type AlbumResponsePayload struct {
|
||||||
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
||||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlaylistInfoMetadata holds playlist information
|
|
||||||
type PlaylistInfoMetadata struct {
|
type PlaylistInfoMetadata struct {
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
@@ -208,13 +193,11 @@ type PlaylistInfoMetadata struct {
|
|||||||
} `json:"owner"`
|
} `json:"owner"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlaylistResponsePayload is the response for playlist requests
|
|
||||||
type PlaylistResponsePayload struct {
|
type PlaylistResponsePayload struct {
|
||||||
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
||||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistInfoMetadata holds artist information
|
|
||||||
type ArtistInfoMetadata struct {
|
type ArtistInfoMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -223,7 +206,6 @@ type ArtistInfoMetadata struct {
|
|||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistAlbumMetadata holds album info for artist discography
|
|
||||||
type ArtistAlbumMetadata struct {
|
type ArtistAlbumMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -234,24 +216,20 @@ type ArtistAlbumMetadata struct {
|
|||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistResponsePayload is the response for artist requests
|
|
||||||
type ArtistResponsePayload struct {
|
type ArtistResponsePayload struct {
|
||||||
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||||
Albums []ArtistAlbumMetadata `json:"albums"`
|
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackResponse is the response for single track requests
|
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
Track TrackMetadata `json:"track"`
|
Track TrackMetadata `json:"track"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResult represents search results
|
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
Tracks []TrackMetadata `json:"tracks"`
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchArtistResult represents an artist in search results
|
|
||||||
type SearchArtistResult struct {
|
type SearchArtistResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -260,10 +238,29 @@ type SearchArtistResult struct {
|
|||||||
Popularity int `json:"popularity"`
|
Popularity int `json:"popularity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAllResult represents combined search results for tracks and artists
|
type SearchAlbumResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchPlaylistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
type SearchAllResult struct {
|
type SearchAllResult struct {
|
||||||
Tracks []TrackMetadata `json:"tracks"`
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
Artists []SearchArtistResult `json:"artists"`
|
Artists []SearchArtistResult `json:"artists"`
|
||||||
|
Albums []SearchAlbumResult `json:"albums"`
|
||||||
|
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type spotifyURI struct {
|
type spotifyURI struct {
|
||||||
@@ -277,7 +274,6 @@ type accessTokenResponse struct {
|
|||||||
TokenType string `json:"token_type"`
|
TokenType string `json:"token_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal API response types
|
|
||||||
type image struct {
|
type image struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
@@ -303,7 +299,7 @@ type albumSimplified struct {
|
|||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
AlbumType string `json:"album_type"` // album, single, compilation
|
AlbumType string `json:"album_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type trackFull struct {
|
type trackFull struct {
|
||||||
@@ -318,7 +314,6 @@ type trackFull struct {
|
|||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFilteredData fetches and formats Spotify data
|
|
||||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||||
parsed, err := parseSpotifyURI(spotifyURL)
|
parsed, err := parseSpotifyURI(spotifyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -344,7 +339,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTracks searches for tracks on Spotify
|
|
||||||
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
|
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
|
||||||
token, err := c.getAccessToken(ctx)
|
token, err := c.getAccessToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -391,12 +385,9 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchAll searches for tracks and artists on Spotify
|
|
||||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
// Create cache key
|
|
||||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -456,7 +447,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit artists to artistLimit
|
|
||||||
artistCount := len(response.Artists.Items)
|
artistCount := len(response.Artists.Items)
|
||||||
if artistCount > artistLimit {
|
if artistCount > artistLimit {
|
||||||
artistCount = artistLimit
|
artistCount = artistLimit
|
||||||
@@ -473,7 +463,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -510,7 +499,6 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -518,7 +506,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Track item structure for pagination
|
|
||||||
type trackItem struct {
|
type trackItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -546,19 +533,25 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
albumImage := firstImageURL(data.Images)
|
albumImage := firstImageURL(data.Images)
|
||||||
|
|
||||||
|
// Get first artist ID
|
||||||
|
var firstArtistId string
|
||||||
|
if len(data.Artists) > 0 {
|
||||||
|
firstArtistId = data.Artists[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: data.TotalTracks,
|
TotalTracks: data.TotalTracks,
|
||||||
Name: data.Name,
|
Name: data.Name,
|
||||||
ReleaseDate: data.ReleaseDate,
|
ReleaseDate: data.ReleaseDate,
|
||||||
Artists: joinArtists(data.Artists),
|
Artists: joinArtists(data.Artists),
|
||||||
|
ArtistId: firstArtistId,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all tracks (including paginated)
|
|
||||||
allTrackItems := data.Tracks.Items
|
allTrackItems := data.Tracks.Items
|
||||||
nextURL := data.Tracks.Next
|
nextURL := data.Tracks.Next
|
||||||
|
|
||||||
// Fetch remaining tracks using pagination (no limit)
|
|
||||||
for nextURL != "" {
|
for nextURL != "" {
|
||||||
var pageData struct {
|
var pageData struct {
|
||||||
Items []trackItem `json:"items"`
|
Items []trackItem `json:"items"`
|
||||||
@@ -580,7 +573,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
trackIDs[i] = item.ID
|
trackIDs[i] = item.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||||
@@ -610,7 +602,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.albumCache[albumID] = &cacheEntry{
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -621,10 +612,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
|
||||||
// Similar to Deezer implementation for consistency
|
|
||||||
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||||
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
const maxParallelISRC = 10
|
||||||
|
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
var resultMu sync.Mutex
|
var resultMu sync.Mutex
|
||||||
@@ -633,7 +622,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use semaphore to limit concurrent requests
|
|
||||||
sem := make(chan struct{}, maxParallelISRC)
|
sem := make(chan struct{}, maxParallelISRC)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
@@ -642,7 +630,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
|||||||
go func(id string) {
|
go func(id string) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
// Acquire semaphore
|
|
||||||
select {
|
select {
|
||||||
case sem <- struct{}{}:
|
case sem <- struct{}{}:
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
@@ -663,7 +650,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
// First request to get playlist info and first batch of tracks
|
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
@@ -689,10 +675,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
info.Owner.Name = data.Name
|
info.Owner.Name = data.Name
|
||||||
info.Owner.Images = firstImageURL(data.Images)
|
info.Owner.Images = firstImageURL(data.Images)
|
||||||
|
|
||||||
// Pre-allocate with expected capacity
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||||
|
|
||||||
// Add first batch of tracks
|
|
||||||
for _, item := range data.Tracks.Items {
|
for _, item := range data.Tracks.Items {
|
||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
@@ -716,7 +700,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
|
||||||
nextURL := data.Tracks.Next
|
nextURL := data.Tracks.Next
|
||||||
|
|
||||||
for nextURL != "" {
|
for nextURL != "" {
|
||||||
@@ -728,7 +711,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
// Log error but return what we have so far
|
|
||||||
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -768,7 +750,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -776,7 +757,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Fetch artist info
|
|
||||||
var artistData struct {
|
var artistData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -799,7 +779,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
Popularity: artistData.Popularity,
|
Popularity: artistData.Popularity,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch artist albums (all types: album, single, compilation)
|
|
||||||
albums := make([]ArtistAlbumMetadata, 0)
|
albums := make([]ArtistAlbumMetadata, 0)
|
||||||
offset := 0
|
offset := 0
|
||||||
limit := 50
|
limit := 50
|
||||||
@@ -839,13 +818,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are more albums
|
|
||||||
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
offset += limit
|
offset += limit
|
||||||
|
|
||||||
// Safety limit to prevent infinite loops
|
|
||||||
if offset > 500 {
|
if offset > 500 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -856,7 +833,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
Albums: albums,
|
Albums: albums,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.artistCache[artistID] = &cacheEntry{
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -927,7 +903,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers (same as PC version baseHeaders)
|
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
@@ -963,8 +938,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
c.rngMu.Lock()
|
c.rngMu.Lock()
|
||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
// Use Mac User-Agent format (same as PC version)
|
macMajor := c.rng.Intn(4) + 11
|
||||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
|
||||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||||
@@ -989,7 +963,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
return spotifyURI{}, errInvalidSpotifyURL
|
return spotifyURI{}, errInvalidSpotifyURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle spotify: URI format
|
|
||||||
if strings.HasPrefix(trimmed, "spotify:") {
|
if strings.HasPrefix(trimmed, "spotify:") {
|
||||||
parts := strings.Split(trimmed, ":")
|
parts := strings.Split(trimmed, ":")
|
||||||
if len(parts) == 3 {
|
if len(parts) == 3 {
|
||||||
@@ -1000,13 +973,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle URL format
|
|
||||||
parsed, err := url.Parse(trimmed)
|
parsed, err := url.Parse(trimmed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return spotifyURI{}, err
|
return spotifyURI{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle embed.spotify.com URLs
|
|
||||||
if parsed.Host == "embed.spotify.com" {
|
if parsed.Host == "embed.spotify.com" {
|
||||||
if parsed.RawQuery == "" {
|
if parsed.RawQuery == "" {
|
||||||
return spotifyURI{}, errInvalidSpotifyURL
|
return spotifyURI{}, errInvalidSpotifyURL
|
||||||
@@ -1019,7 +990,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
return parseSpotifyURI(embedded)
|
return parseSpotifyURI(embedded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle plain ID (no scheme/host) - defaults to playlist
|
|
||||||
if parsed.Scheme == "" && parsed.Host == "" {
|
if parsed.Scheme == "" && parsed.Host == "" {
|
||||||
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
|
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@@ -1045,7 +1015,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
return spotifyURI{}, errInvalidSpotifyURL
|
return spotifyURI{}, errInvalidSpotifyURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip intl- prefix if present
|
|
||||||
if strings.HasPrefix(parts[0], "intl-") {
|
if strings.HasPrefix(parts[0], "intl-") {
|
||||||
parts = parts[1:]
|
parts = parts[1:]
|
||||||
}
|
}
|
||||||
@@ -1053,7 +1022,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
return spotifyURI{}, errInvalidSpotifyURL
|
return spotifyURI{}, errInvalidSpotifyURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
|
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
switch parts[0] {
|
switch parts[0] {
|
||||||
case "album", "track", "playlist", "artist":
|
case "album", "track", "playlist", "artist":
|
||||||
@@ -1061,7 +1029,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle nested playlist URLs: /user/{user}/playlist/{id}
|
|
||||||
if len(parts) == 4 && parts[2] == "playlist" {
|
if len(parts) == 4 && parts[2] == "playlist" {
|
||||||
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
|
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -161,7 +182,8 @@ import Gobackend // Import Go framework
|
|||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error)
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -171,7 +193,8 @@ import Gobackend // Import Go framework
|
|||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let filePath = args["file_path"] as? String ?? ""
|
let filePath = args["file_path"] as? String ?? ""
|
||||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -199,7 +222,8 @@ import Gobackend // Import Go framework
|
|||||||
let query = args["query"] as! String
|
let query = args["query"] as! String
|
||||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||||
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error)
|
let filter = args["filter"] as? String ?? ""
|
||||||
|
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -225,6 +249,13 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "getDeezerExtendedMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let trackId = args["track_id"] as! String
|
||||||
|
let response = GobackendGetDeezerExtendedMetadata(trackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "convertSpotifyToDeezer":
|
case "convertSpotifyToDeezer":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let resourceType = args["resource_type"] as! String
|
let resourceType = args["resource_type"] as! String
|
||||||
@@ -240,6 +271,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
|
||||||
@@ -373,6 +441,14 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "invokeExtensionAction":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let actionName = args["action"] as! String
|
||||||
|
let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "searchTracksWithExtensions":
|
case "searchTracksWithExtensions":
|
||||||
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
|
||||||
@@ -387,6 +463,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
|
||||||
@@ -588,6 +672,21 @@ 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
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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) {
|
||||||
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
|
||||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
@@ -35,10 +34,14 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
final router = ref.watch(_routerProvider);
|
final router = ref.watch(_routerProvider);
|
||||||
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||||
|
|
||||||
// Convert locale string to Locale object
|
|
||||||
Locale? locale;
|
Locale? locale;
|
||||||
if (localeString != 'system') {
|
if (localeString != 'system') {
|
||||||
locale = Locale(localeString);
|
if (localeString.contains('_')) {
|
||||||
|
final parts = localeString.split('_');
|
||||||
|
locale = Locale(parts[0], parts[1]);
|
||||||
|
} else {
|
||||||
|
locale = Locale(localeString);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return DynamicColorWrapper(
|
return DynamicColorWrapper(
|
||||||
@@ -52,8 +55,7 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||||
themeAnimationCurve: Curves.easeInOut,
|
themeAnimationCurve: Curves.easeInOut,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
// Localization
|
locale: locale,
|
||||||
locale: locale, // null = follow system
|
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
AppLocalizations.delegate,
|
AppLocalizations.delegate,
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
|||||||
@@ -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.0';
|
static const String version = '3.3.1';
|
||||||
static const String buildNumber = '59';
|
static const String buildNumber = '68';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'app_localizations_ko.dart';
|
|||||||
import 'app_localizations_nl.dart';
|
import 'app_localizations_nl.dart';
|
||||||
import 'app_localizations_pt.dart';
|
import 'app_localizations_pt.dart';
|
||||||
import 'app_localizations_ru.dart';
|
import 'app_localizations_ru.dart';
|
||||||
|
import 'app_localizations_tr.dart';
|
||||||
import 'app_localizations_zh.dart';
|
import 'app_localizations_zh.dart';
|
||||||
|
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
@@ -107,6 +108,7 @@ abstract class AppLocalizations {
|
|||||||
Locale('de'),
|
Locale('de'),
|
||||||
Locale('en'),
|
Locale('en'),
|
||||||
Locale('es'),
|
Locale('es'),
|
||||||
|
Locale('es', 'ES'),
|
||||||
Locale('fr'),
|
Locale('fr'),
|
||||||
Locale('hi'),
|
Locale('hi'),
|
||||||
Locale('id'),
|
Locale('id'),
|
||||||
@@ -114,7 +116,9 @@ abstract class AppLocalizations {
|
|||||||
Locale('ko'),
|
Locale('ko'),
|
||||||
Locale('nl'),
|
Locale('nl'),
|
||||||
Locale('pt'),
|
Locale('pt'),
|
||||||
|
Locale('pt', 'PT'),
|
||||||
Locale('ru'),
|
Locale('ru'),
|
||||||
|
Locale('tr'),
|
||||||
Locale('zh'),
|
Locale('zh'),
|
||||||
Locale('zh', 'CN'),
|
Locale('zh', 'CN'),
|
||||||
Locale('zh', 'TW'),
|
Locale('zh', 'TW'),
|
||||||
@@ -276,6 +280,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Single track downloads will appear here'**
|
/// **'Single track downloads will appear here'**
|
||||||
String get historyNoSinglesSubtitle;
|
String get historyNoSinglesSubtitle;
|
||||||
|
|
||||||
|
/// Search bar placeholder in history
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search history...'**
|
||||||
|
String get historySearchHint;
|
||||||
|
|
||||||
/// Settings screen title
|
/// Settings screen title
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -816,6 +826,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'The talented artist who created our beautiful app logo!'**
|
/// **'The talented artist who created our beautiful app logo!'**
|
||||||
String get aboutLogoArtist;
|
String get aboutLogoArtist;
|
||||||
|
|
||||||
|
/// Section for translators
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Translators'**
|
||||||
|
String get aboutTranslators;
|
||||||
|
|
||||||
/// Section for special thanks
|
/// Section for special thanks
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -864,6 +880,36 @@ abstract class AppLocalizations {
|
|||||||
/// **'Suggest new features for the app'**
|
/// **'Suggest new features for the app'**
|
||||||
String get aboutFeatureRequestSubtitle;
|
String get aboutFeatureRequestSubtitle;
|
||||||
|
|
||||||
|
/// Link to Telegram channel
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telegram Channel'**
|
||||||
|
String get aboutTelegramChannel;
|
||||||
|
|
||||||
|
/// Subtitle for Telegram channel
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Announcements and updates'**
|
||||||
|
String get aboutTelegramChannelSubtitle;
|
||||||
|
|
||||||
|
/// Link to Telegram chat group
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Telegram Community'**
|
||||||
|
String get aboutTelegramChat;
|
||||||
|
|
||||||
|
/// Subtitle for Telegram chat
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Chat with other users'**
|
||||||
|
String get aboutTelegramChatSubtitle;
|
||||||
|
|
||||||
|
/// Section for social links
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Social'**
|
||||||
|
String get aboutSocial;
|
||||||
|
|
||||||
/// Section for support/donation links
|
/// Section for support/donation links
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -906,6 +952,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'The original HiFi project creator. The foundation of Tidal integration!'**
|
/// **'The original HiFi project creator. The foundation of Tidal integration!'**
|
||||||
String get aboutSachinsenalDesc;
|
String get aboutSachinsenalDesc;
|
||||||
|
|
||||||
|
/// Credit description for sjdonado
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!'**
|
||||||
|
String get aboutSjdonadoDesc;
|
||||||
|
|
||||||
/// Name of Amazon API service - DO NOT TRANSLATE
|
/// Name of Amazon API service - DO NOT TRANSLATE
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1680,6 +1732,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Found {count} tracks in CSV. Add them to download queue?'**
|
/// **'Found {count} tracks in CSV. Add them to download queue?'**
|
||||||
String dialogImportPlaylistMessage(int count);
|
String dialogImportPlaylistMessage(int count);
|
||||||
|
|
||||||
|
/// Label shown in quality picker for CSV import
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} tracks from CSV'**
|
||||||
|
String csvImportTracks(int count);
|
||||||
|
|
||||||
/// Snackbar - track added to download queue
|
/// Snackbar - track added to download queue
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2604,6 +2662,60 @@ abstract class AppLocalizations {
|
|||||||
/// **'File Settings'**
|
/// **'File Settings'**
|
||||||
String get sectionFileSettings;
|
String get sectionFileSettings;
|
||||||
|
|
||||||
|
/// Settings section header
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics'**
|
||||||
|
String get sectionLyrics;
|
||||||
|
|
||||||
|
/// Setting - how to save lyrics
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics Mode'**
|
||||||
|
String get lyricsMode;
|
||||||
|
|
||||||
|
/// Lyrics mode picker description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose how lyrics are saved with your downloads'**
|
||||||
|
String get lyricsModeDescription;
|
||||||
|
|
||||||
|
/// Lyrics mode option - embed in audio file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Embed in file'**
|
||||||
|
String get lyricsModeEmbed;
|
||||||
|
|
||||||
|
/// Subtitle for embed option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics stored inside FLAC metadata'**
|
||||||
|
String get lyricsModeEmbedSubtitle;
|
||||||
|
|
||||||
|
/// Lyrics mode option - separate LRC file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'External .lrc file'**
|
||||||
|
String get lyricsModeExternal;
|
||||||
|
|
||||||
|
/// Subtitle for external option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Separate .lrc file for players like Samsung Music'**
|
||||||
|
String get lyricsModeExternalSubtitle;
|
||||||
|
|
||||||
|
/// Lyrics mode option - embed and external
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Both'**
|
||||||
|
String get lyricsModeBoth;
|
||||||
|
|
||||||
|
/// Subtitle for both option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Embed and save .lrc file'**
|
||||||
|
String get lyricsModeBothSubtitle;
|
||||||
|
|
||||||
/// Settings section header
|
/// Settings section header
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2808,6 +2920,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Release date'**
|
/// **'Release date'**
|
||||||
String get trackReleaseDate;
|
String get trackReleaseDate;
|
||||||
|
|
||||||
|
/// Metadata label - music genre
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Genre'**
|
||||||
|
String get trackGenre;
|
||||||
|
|
||||||
|
/// Metadata label - record label
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Label'**
|
||||||
|
String get trackLabel;
|
||||||
|
|
||||||
|
/// Metadata label - copyright information
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Copyright'**
|
||||||
|
String get trackCopyright;
|
||||||
|
|
||||||
/// Metadata label - download date
|
/// Metadata label - download date
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2838,6 +2968,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Failed to load lyrics'**
|
/// **'Failed to load lyrics'**
|
||||||
String get trackLyricsLoadFailed;
|
String get trackLyricsLoadFailed;
|
||||||
|
|
||||||
|
/// Action - embed lyrics into audio file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Embed Lyrics'**
|
||||||
|
String get trackEmbedLyrics;
|
||||||
|
|
||||||
|
/// Snackbar - lyrics saved to file
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lyrics embedded successfully'**
|
||||||
|
String get trackLyricsEmbedded;
|
||||||
|
|
||||||
|
/// Message when track is instrumental (no lyrics)
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Instrumental track'**
|
||||||
|
String get trackInstrumental;
|
||||||
|
|
||||||
/// Snackbar - content copied
|
/// Snackbar - content copied
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3252,6 +3400,66 @@ abstract class AppLocalizations {
|
|||||||
/// **'24-bit / up to 192kHz'**
|
/// **'24-bit / up to 192kHz'**
|
||||||
String get qualityHiResFlacMaxSubtitle;
|
String get qualityHiResFlacMaxSubtitle;
|
||||||
|
|
||||||
|
/// Quality option - lossy format (MP3/Opus)
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossy'**
|
||||||
|
String get qualityLossy;
|
||||||
|
|
||||||
|
/// Technical spec for lossy MP3
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'MP3 320kbps (converted from FLAC)'**
|
||||||
|
String get qualityLossyMp3Subtitle;
|
||||||
|
|
||||||
|
/// Technical spec for lossy Opus
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Opus 128kbps (converted from FLAC)'**
|
||||||
|
String get qualityLossyOpusSubtitle;
|
||||||
|
|
||||||
|
/// Setting - enable lossy quality option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enable Lossy Option'**
|
||||||
|
String get enableLossyOption;
|
||||||
|
|
||||||
|
/// Subtitle when lossy is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossy quality option is available'**
|
||||||
|
String get enableLossyOptionSubtitleOn;
|
||||||
|
|
||||||
|
/// Subtitle when lossy is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Downloads FLAC then converts to lossy format'**
|
||||||
|
String get enableLossyOptionSubtitleOff;
|
||||||
|
|
||||||
|
/// Setting - choose lossy format
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossy Format'**
|
||||||
|
String get lossyFormat;
|
||||||
|
|
||||||
|
/// Description for lossy format picker
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose the lossy format for conversion'**
|
||||||
|
String get lossyFormatDescription;
|
||||||
|
|
||||||
|
/// MP3 format description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'320kbps, best compatibility'**
|
||||||
|
String get lossyFormatMp3Subtitle;
|
||||||
|
|
||||||
|
/// Opus format description
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'128kbps, better quality at smaller size'**
|
||||||
|
String get lossyFormatOpusSubtitle;
|
||||||
|
|
||||||
/// Note about quality availability
|
/// Note about quality availability
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3534,6 +3742,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Albums/[2005] Album Name/'**
|
/// **'Albums/[2005] Album Name/'**
|
||||||
String get albumFolderYearAlbumSubtitle;
|
String get albumFolderYearAlbumSubtitle;
|
||||||
|
|
||||||
|
/// Album folder option with singles inside artist
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist / Album + Singles'**
|
||||||
|
String get albumFolderArtistAlbumSingles;
|
||||||
|
|
||||||
|
/// Folder structure example
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Artist/Album/ and Artist/Singles/'**
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle;
|
||||||
|
|
||||||
/// Button - delete selected tracks
|
/// Button - delete selected tracks
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3588,6 +3808,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Select tracks to delete'**
|
/// **'Select tracks to delete'**
|
||||||
String get downloadedAlbumSelectToDelete;
|
String get downloadedAlbumSelectToDelete;
|
||||||
|
|
||||||
|
/// Header for disc separator in multi-disc albums
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disc {discNumber}'**
|
||||||
|
String downloadedAlbumDiscHeader(int discNumber);
|
||||||
|
|
||||||
/// Extension capability - utility functions
|
/// Extension capability - utility functions
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3629,6 +3855,150 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Error: {message}'**
|
/// **'Error: {message}'**
|
||||||
String errorGeneric(String message);
|
String errorGeneric(String message);
|
||||||
|
|
||||||
|
/// Button - download artist discography
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download Discography'**
|
||||||
|
String get discographyDownload;
|
||||||
|
|
||||||
|
/// Option - download entire discography
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download All'**
|
||||||
|
String get discographyDownloadAll;
|
||||||
|
|
||||||
|
/// Subtitle showing total tracks and albums
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} tracks from {albumCount} releases'**
|
||||||
|
String discographyDownloadAllSubtitle(int count, int albumCount);
|
||||||
|
|
||||||
|
/// Option - download only albums
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Albums Only'**
|
||||||
|
String get discographyAlbumsOnly;
|
||||||
|
|
||||||
|
/// Subtitle showing album tracks count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} tracks from {albumCount} albums'**
|
||||||
|
String discographyAlbumsOnlySubtitle(int count, int albumCount);
|
||||||
|
|
||||||
|
/// Option - download only singles
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Singles & EPs Only'**
|
||||||
|
String get discographySinglesOnly;
|
||||||
|
|
||||||
|
/// Subtitle showing singles tracks count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} tracks from {albumCount} singles'**
|
||||||
|
String discographySinglesOnlySubtitle(int count, int albumCount);
|
||||||
|
|
||||||
|
/// Option - manually select albums to download
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select Albums...'**
|
||||||
|
String get discographySelectAlbums;
|
||||||
|
|
||||||
|
/// Subtitle for select albums option
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose specific albums or singles'**
|
||||||
|
String get discographySelectAlbumsSubtitle;
|
||||||
|
|
||||||
|
/// Progress - fetching album tracks
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Fetching tracks...'**
|
||||||
|
String get discographyFetchingTracks;
|
||||||
|
|
||||||
|
/// Progress - fetching specific album
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Fetching {current} of {total}...'**
|
||||||
|
String discographyFetchingAlbum(int current, int total);
|
||||||
|
|
||||||
|
/// Selection count badge
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} selected'**
|
||||||
|
String discographySelectedCount(int count);
|
||||||
|
|
||||||
|
/// Button - download selected albums
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download Selected'**
|
||||||
|
String get discographyDownloadSelected;
|
||||||
|
|
||||||
|
/// Snackbar - tracks added from discography
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Added {count} tracks to queue'**
|
||||||
|
String discographyAddedToQueue(int count);
|
||||||
|
|
||||||
|
/// Snackbar - with skipped tracks count
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{added} added, {skipped} already downloaded'**
|
||||||
|
String discographySkippedDownloaded(int added, int skipped);
|
||||||
|
|
||||||
|
/// Error - no albums found for artist
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No albums available'**
|
||||||
|
String get discographyNoAlbums;
|
||||||
|
|
||||||
|
/// Error - some albums failed to load
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Failed to fetch some albums'**
|
||||||
|
String get discographyFailedToFetch;
|
||||||
|
|
||||||
|
/// Section header for storage access settings
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Storage Access'**
|
||||||
|
String get sectionStorageAccess;
|
||||||
|
|
||||||
|
/// Toggle for MANAGE_EXTERNAL_STORAGE permission
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All Files Access'**
|
||||||
|
String get allFilesAccess;
|
||||||
|
|
||||||
|
/// Subtitle when all files access is enabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Can write to any folder'**
|
||||||
|
String get allFilesAccessEnabledSubtitle;
|
||||||
|
|
||||||
|
/// Subtitle when all files access is disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Limited to media folders only'**
|
||||||
|
String get allFilesAccessDisabledSubtitle;
|
||||||
|
|
||||||
|
/// Description explaining when to enable all files access
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.'**
|
||||||
|
String get allFilesAccessDescription;
|
||||||
|
|
||||||
|
/// Message when permission is permanently denied
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Permission was denied. Please enable \'All files access\' manually in system settings.'**
|
||||||
|
String get allFilesAccessDeniedMessage;
|
||||||
|
|
||||||
|
/// Snackbar message when user disables all files access
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All Files Access disabled. The app will use limited storage access.'**
|
||||||
|
String get allFilesAccessDisabledMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
@@ -3653,6 +4023,7 @@ class _AppLocalizationsDelegate
|
|||||||
'nl',
|
'nl',
|
||||||
'pt',
|
'pt',
|
||||||
'ru',
|
'ru',
|
||||||
|
'tr',
|
||||||
'zh',
|
'zh',
|
||||||
].contains(locale.languageCode);
|
].contains(locale.languageCode);
|
||||||
|
|
||||||
@@ -3663,6 +4034,22 @@ class _AppLocalizationsDelegate
|
|||||||
AppLocalizations lookupAppLocalizations(Locale locale) {
|
AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||||
// Lookup logic when language+country codes are specified.
|
// Lookup logic when language+country codes are specified.
|
||||||
switch (locale.languageCode) {
|
switch (locale.languageCode) {
|
||||||
|
case 'es':
|
||||||
|
{
|
||||||
|
switch (locale.countryCode) {
|
||||||
|
case 'ES':
|
||||||
|
return AppLocalizationsEsEs();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'pt':
|
||||||
|
{
|
||||||
|
switch (locale.countryCode) {
|
||||||
|
case 'PT':
|
||||||
|
return AppLocalizationsPtPt();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'zh':
|
case 'zh':
|
||||||
{
|
{
|
||||||
switch (locale.countryCode) {
|
switch (locale.countryCode) {
|
||||||
@@ -3699,6 +4086,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
|||||||
return AppLocalizationsPt();
|
return AppLocalizationsPt();
|
||||||
case 'ru':
|
case 'ru':
|
||||||
return AppLocalizationsRu();
|
return AppLocalizationsRu();
|
||||||
|
case 'tr':
|
||||||
|
return AppLocalizationsTr();
|
||||||
case 'zh':
|
case 'zh':
|
||||||
return AppLocalizationsZh();
|
return AppLocalizationsZh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,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';
|
||||||
|
|
||||||
@@ -402,6 +405,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get aboutLogoArtist =>
|
String get aboutLogoArtist =>
|
||||||
'The talented artist who created our beautiful app logo!';
|
'The talented artist who created our beautiful app logo!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTranslators => 'Translators';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSpecialThanks => 'Special Thanks';
|
String get aboutSpecialThanks => 'Special Thanks';
|
||||||
|
|
||||||
@@ -426,6 +432,21 @@ 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';
|
||||||
|
|
||||||
@@ -449,6 +470,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';
|
||||||
|
|
||||||
@@ -894,6 +919,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String csvImportTracks(int count) {
|
||||||
|
return '$count tracks from CSV';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String snackbarAddedToQueue(String trackName) {
|
String snackbarAddedToQueue(String trackName) {
|
||||||
return 'Added \"$trackName\" to queue';
|
return 'Added \"$trackName\" to queue';
|
||||||
@@ -1427,6 +1457,35 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sectionFileSettings => 'File Settings';
|
String get sectionFileSettings => 'File Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsMode => 'Lyrics Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeDescription =>
|
||||||
|
'Choose how lyrics are saved with your downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbed => 'Embed in file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternal => 'External .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternalSubtitle =>
|
||||||
|
'Separate .lrc file for players like Samsung Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBoth => 'Both';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionColor => 'Color';
|
String get sectionColor => 'Color';
|
||||||
|
|
||||||
@@ -1539,6 +1598,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReleaseDate => 'Release date';
|
String get trackReleaseDate => 'Release date';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackGenre => 'Genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCopyright => 'Copyright';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackDownloaded => 'Downloaded';
|
String get trackDownloaded => 'Downloaded';
|
||||||
|
|
||||||
@@ -1554,6 +1622,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';
|
||||||
|
|
||||||
@@ -1782,6 +1859,38 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
|
|
||||||
|
@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';
|
||||||
@@ -1926,6 +2035,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';
|
||||||
|
|
||||||
@@ -1973,6 +2089,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadedAlbumDiscHeader(int discNumber) {
|
||||||
|
return 'Disc $discNumber';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
String get utilityFunctions => 'Utility Functions';
|
||||||
|
|
||||||
@@ -1997,4 +2118,94 @@ 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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,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';
|
||||||
|
|
||||||
@@ -402,6 +405,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get aboutLogoArtist =>
|
String get aboutLogoArtist =>
|
||||||
'The talented artist who created our beautiful app logo!';
|
'The talented artist who created our beautiful app logo!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTranslators => 'Translators';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSpecialThanks => 'Special Thanks';
|
String get aboutSpecialThanks => 'Special Thanks';
|
||||||
|
|
||||||
@@ -426,6 +432,21 @@ 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';
|
||||||
|
|
||||||
@@ -449,6 +470,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';
|
||||||
|
|
||||||
@@ -894,6 +919,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String csvImportTracks(int count) {
|
||||||
|
return '$count tracks from CSV';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String snackbarAddedToQueue(String trackName) {
|
String snackbarAddedToQueue(String trackName) {
|
||||||
return 'Added \"$trackName\" to queue';
|
return 'Added \"$trackName\" to queue';
|
||||||
@@ -1427,6 +1457,35 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sectionFileSettings => 'File Settings';
|
String get sectionFileSettings => 'File Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsMode => 'Lyrics Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeDescription =>
|
||||||
|
'Choose how lyrics are saved with your downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbed => 'Embed in file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternal => 'External .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternalSubtitle =>
|
||||||
|
'Separate .lrc file for players like Samsung Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBoth => 'Both';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionColor => 'Color';
|
String get sectionColor => 'Color';
|
||||||
|
|
||||||
@@ -1539,6 +1598,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReleaseDate => 'Release date';
|
String get trackReleaseDate => 'Release date';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackGenre => 'Genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCopyright => 'Copyright';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackDownloaded => 'Downloaded';
|
String get trackDownloaded => 'Downloaded';
|
||||||
|
|
||||||
@@ -1554,6 +1622,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';
|
||||||
|
|
||||||
@@ -1782,6 +1859,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
|
|
||||||
|
@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';
|
||||||
@@ -1926,6 +2035,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';
|
||||||
|
|
||||||
@@ -1973,6 +2089,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadedAlbumDiscHeader(int discNumber) {
|
||||||
|
return 'Disc $discNumber';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
String get utilityFunctions => 'Utility Functions';
|
||||||
|
|
||||||
@@ -1997,4 +2118,94 @@ 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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,20 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
AppLocalizationsHi([String locale = 'hi']) : super(locale);
|
AppLocalizationsHi([String locale = 'hi']) : super(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appName => 'SpotiFLAC';
|
String get appName => 'SpotiFlac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appDescription =>
|
String get appDescription =>
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
'स्पॉटीफाई ट्रैक डाउनलोड करें टाइडल, क्वाबज एवं एवं अमेजन म्यूजिक से उच्चतम क्वालिटी में।';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHome => 'Home';
|
String get navHome => 'होम';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navHistory => 'History';
|
String get navHistory => 'इतिहास';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navSettings => 'Settings';
|
String get navSettings => 'विकल्प';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get navStore => 'Store';
|
String get navStore => 'Store';
|
||||||
@@ -109,6 +109,9 @@ class AppLocalizationsHi 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';
|
||||||
|
|
||||||
@@ -181,7 +184,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get quality128 => '128 kbps';
|
String get quality128 => '128 kbps';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTitle => 'Appearance';
|
String get appearanceTitle => 'दिखावट';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceTheme => 'Theme';
|
String get appearanceTheme => 'Theme';
|
||||||
@@ -196,10 +199,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get appearanceThemeDark => 'Dark';
|
String get appearanceThemeDark => 'Dark';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColor => 'Dynamic Color';
|
String get appearanceDynamicColor => 'डायनेमिक रंग';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
String get appearanceDynamicColorSubtitle => 'वॉलपेपर से रंग इस्तेमाल करें';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get appearanceAccentColor => 'Accent Color';
|
String get appearanceAccentColor => 'Accent Color';
|
||||||
@@ -402,6 +405,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get aboutLogoArtist =>
|
String get aboutLogoArtist =>
|
||||||
'The talented artist who created our beautiful app logo!';
|
'The talented artist who created our beautiful app logo!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTranslators => 'Translators';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSpecialThanks => 'Special Thanks';
|
String get aboutSpecialThanks => 'Special Thanks';
|
||||||
|
|
||||||
@@ -426,6 +432,21 @@ class AppLocalizationsHi 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';
|
||||||
|
|
||||||
@@ -449,6 +470,10 @@ class AppLocalizationsHi 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';
|
||||||
|
|
||||||
@@ -894,6 +919,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String csvImportTracks(int count) {
|
||||||
|
return '$count tracks from CSV';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String snackbarAddedToQueue(String trackName) {
|
String snackbarAddedToQueue(String trackName) {
|
||||||
return 'Added \"$trackName\" to queue';
|
return 'Added \"$trackName\" to queue';
|
||||||
@@ -1427,6 +1457,35 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sectionFileSettings => 'File Settings';
|
String get sectionFileSettings => 'File Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsMode => 'Lyrics Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeDescription =>
|
||||||
|
'Choose how lyrics are saved with your downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbed => 'Embed in file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternal => 'External .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternalSubtitle =>
|
||||||
|
'Separate .lrc file for players like Samsung Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBoth => 'Both';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionColor => 'Color';
|
String get sectionColor => 'Color';
|
||||||
|
|
||||||
@@ -1539,6 +1598,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReleaseDate => 'Release date';
|
String get trackReleaseDate => 'Release date';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackGenre => 'Genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCopyright => 'Copyright';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackDownloaded => 'Downloaded';
|
String get trackDownloaded => 'Downloaded';
|
||||||
|
|
||||||
@@ -1554,6 +1622,15 @@ class AppLocalizationsHi 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';
|
||||||
|
|
||||||
@@ -1782,6 +1859,38 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
|
|
||||||
|
@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';
|
||||||
@@ -1926,6 +2035,13 @@ class AppLocalizationsHi 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';
|
||||||
|
|
||||||
@@ -1973,6 +2089,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadedAlbumDiscHeader(int discNumber) {
|
||||||
|
return 'Disc $discNumber';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
String get utilityFunctions => 'Utility Functions';
|
||||||
|
|
||||||
@@ -1997,4 +2118,94 @@ class AppLocalizationsHi 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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get historyNoSinglesSubtitle =>
|
String get historyNoSinglesSubtitle =>
|
||||||
'Unduhan lagu satuan akan muncul di sini';
|
'Unduhan lagu satuan akan muncul di sini';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get historySearchHint => 'Search history...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Pengaturan';
|
String get settingsTitle => 'Pengaturan';
|
||||||
|
|
||||||
@@ -406,6 +409,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get aboutLogoArtist =>
|
String get aboutLogoArtist =>
|
||||||
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
|
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTranslators => 'Translators';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSpecialThanks => 'Terima Kasih Khusus';
|
String get aboutSpecialThanks => 'Terima Kasih Khusus';
|
||||||
|
|
||||||
@@ -431,6 +437,21 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get aboutFeatureRequestSubtitle =>
|
String get aboutFeatureRequestSubtitle =>
|
||||||
'Sarankan fitur baru untuk aplikasi';
|
'Sarankan fitur baru untuk aplikasi';
|
||||||
|
|
||||||
|
@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 => 'Dukungan';
|
String get aboutSupport => 'Dukungan';
|
||||||
|
|
||||||
@@ -454,6 +475,10 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get aboutSachinsenalDesc =>
|
String get aboutSachinsenalDesc =>
|
||||||
'Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!';
|
'Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!';
|
||||||
|
|
||||||
|
@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';
|
||||||
|
|
||||||
@@ -900,6 +925,11 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?';
|
return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String csvImportTracks(int count) {
|
||||||
|
return '$count tracks from CSV';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String snackbarAddedToQueue(String trackName) {
|
String snackbarAddedToQueue(String trackName) {
|
||||||
return 'Menambahkan \"$trackName\" ke antrian';
|
return 'Menambahkan \"$trackName\" ke antrian';
|
||||||
@@ -1437,6 +1467,35 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sectionFileSettings => 'Pengaturan File';
|
String get sectionFileSettings => 'Pengaturan File';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsMode => 'Lyrics Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeDescription =>
|
||||||
|
'Choose how lyrics are saved with your downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbed => 'Embed in file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternal => 'External .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternalSubtitle =>
|
||||||
|
'Separate .lrc file for players like Samsung Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBoth => 'Both';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionColor => 'Warna';
|
String get sectionColor => 'Warna';
|
||||||
|
|
||||||
@@ -1549,6 +1608,15 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReleaseDate => 'Tanggal rilis';
|
String get trackReleaseDate => 'Tanggal rilis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackGenre => 'Genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCopyright => 'Copyright';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackDownloaded => 'Diunduh';
|
String get trackDownloaded => 'Diunduh';
|
||||||
|
|
||||||
@@ -1564,6 +1632,15 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackInstrumental => 'Instrumental track';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
||||||
|
|
||||||
@@ -1794,6 +1871,38 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
|
|
||||||
|
@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 =>
|
||||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||||
@@ -1939,6 +2048,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||||
|
|
||||||
@@ -1986,6 +2102,11 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus';
|
String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadedAlbumDiscHeader(int discNumber) {
|
||||||
|
return 'Disc $discNumber';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Fungsi Utilitas';
|
String get utilityFunctions => 'Fungsi Utilitas';
|
||||||
|
|
||||||
@@ -2010,4 +2131,94 @@ class AppLocalizationsId 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 => 'Unduh Semua';
|
||||||
|
|
||||||
|
@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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,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';
|
||||||
|
|
||||||
@@ -402,6 +405,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get aboutLogoArtist =>
|
String get aboutLogoArtist =>
|
||||||
'The talented artist who created our beautiful app logo!';
|
'The talented artist who created our beautiful app logo!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTranslators => 'Translators';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSpecialThanks => 'Special Thanks';
|
String get aboutSpecialThanks => 'Special Thanks';
|
||||||
|
|
||||||
@@ -426,6 +432,21 @@ 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';
|
||||||
|
|
||||||
@@ -449,6 +470,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';
|
||||||
|
|
||||||
@@ -894,6 +919,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String csvImportTracks(int count) {
|
||||||
|
return '$count tracks from CSV';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String snackbarAddedToQueue(String trackName) {
|
String snackbarAddedToQueue(String trackName) {
|
||||||
return 'Added \"$trackName\" to queue';
|
return 'Added \"$trackName\" to queue';
|
||||||
@@ -1427,6 +1457,35 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sectionFileSettings => 'File Settings';
|
String get sectionFileSettings => 'File Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsMode => 'Lyrics Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeDescription =>
|
||||||
|
'Choose how lyrics are saved with your downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbed => 'Embed in file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternal => 'External .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternalSubtitle =>
|
||||||
|
'Separate .lrc file for players like Samsung Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBoth => 'Both';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionColor => 'Color';
|
String get sectionColor => 'Color';
|
||||||
|
|
||||||
@@ -1539,6 +1598,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReleaseDate => 'Release date';
|
String get trackReleaseDate => 'Release date';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackGenre => 'Genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCopyright => 'Copyright';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackDownloaded => 'Downloaded';
|
String get trackDownloaded => 'Downloaded';
|
||||||
|
|
||||||
@@ -1554,6 +1622,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';
|
||||||
|
|
||||||
@@ -1782,6 +1859,38 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
|
|
||||||
|
@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';
|
||||||
@@ -1926,6 +2035,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';
|
||||||
|
|
||||||
@@ -1973,6 +2089,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadedAlbumDiscHeader(int discNumber) {
|
||||||
|
return 'Disc $discNumber';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
String get utilityFunctions => 'Utility Functions';
|
||||||
|
|
||||||
@@ -1997,4 +2118,94 @@ 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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,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';
|
||||||
|
|
||||||
@@ -402,6 +405,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get aboutLogoArtist =>
|
String get aboutLogoArtist =>
|
||||||
'The talented artist who created our beautiful app logo!';
|
'The talented artist who created our beautiful app logo!';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutTranslators => 'Translators';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSpecialThanks => 'Special Thanks';
|
String get aboutSpecialThanks => 'Special Thanks';
|
||||||
|
|
||||||
@@ -426,6 +432,21 @@ 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';
|
||||||
|
|
||||||
@@ -449,6 +470,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';
|
||||||
|
|
||||||
@@ -894,6 +919,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return 'Found $count tracks in CSV. Add them to download queue?';
|
return 'Found $count tracks in CSV. Add them to download queue?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String csvImportTracks(int count) {
|
||||||
|
return '$count tracks from CSV';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String snackbarAddedToQueue(String trackName) {
|
String snackbarAddedToQueue(String trackName) {
|
||||||
return 'Added \"$trackName\" to queue';
|
return 'Added \"$trackName\" to queue';
|
||||||
@@ -1427,6 +1457,35 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sectionFileSettings => 'File Settings';
|
String get sectionFileSettings => 'File Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsMode => 'Lyrics Mode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeDescription =>
|
||||||
|
'Choose how lyrics are saved with your downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbed => 'Embed in file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternal => 'External .lrc file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeExternalSubtitle =>
|
||||||
|
'Separate .lrc file for players like Samsung Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBoth => 'Both';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sectionColor => 'Color';
|
String get sectionColor => 'Color';
|
||||||
|
|
||||||
@@ -1539,6 +1598,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReleaseDate => 'Release date';
|
String get trackReleaseDate => 'Release date';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackGenre => 'Genre';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackLabel => 'Label';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCopyright => 'Copyright';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackDownloaded => 'Downloaded';
|
String get trackDownloaded => 'Downloaded';
|
||||||
|
|
||||||
@@ -1554,6 +1622,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';
|
||||||
|
|
||||||
@@ -1782,6 +1859,38 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossy => 'Lossy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyMp3Subtitle => 'MP3 320kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get qualityLossyOpusSubtitle => 'Opus 128kbps (converted from FLAC)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOption => 'Enable Lossy Option';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableLossyOptionSubtitleOn => 'Lossy quality option is available';
|
||||||
|
|
||||||
|
@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';
|
||||||
@@ -1926,6 +2035,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';
|
||||||
|
|
||||||
@@ -1973,6 +2089,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String downloadedAlbumDiscHeader(int discNumber) {
|
||||||
|
return 'Disc $discNumber';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get utilityFunctions => 'Utility Functions';
|
String get utilityFunctions => 'Utility Functions';
|
||||||
|
|
||||||
@@ -1997,4 +2118,94 @@ 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.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,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"},
|
||||||
@@ -290,6 +292,8 @@
|
|||||||
"@aboutOriginalCreator": {"description": "Role description for original creator"},
|
"@aboutOriginalCreator": {"description": "Role description for original creator"},
|
||||||
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
|
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
|
||||||
"@aboutLogoArtist": {"description": "Role description for logo artist"},
|
"@aboutLogoArtist": {"description": "Role description for logo artist"},
|
||||||
|
"aboutTranslators": "Translators",
|
||||||
|
"@aboutTranslators": {"description": "Section for translators"},
|
||||||
"aboutSpecialThanks": "Special Thanks",
|
"aboutSpecialThanks": "Special Thanks",
|
||||||
"@aboutSpecialThanks": {"description": "Section for special thanks"},
|
"@aboutSpecialThanks": {"description": "Section for special thanks"},
|
||||||
"aboutLinks": "Links",
|
"aboutLinks": "Links",
|
||||||
@@ -302,10 +306,20 @@
|
|||||||
"@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": "Buy me a coffee",
|
||||||
@@ -320,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!",
|
||||||
@@ -617,6 +633,13 @@
|
|||||||
"dialogImportPlaylistTitle": "Import Playlist",
|
"dialogImportPlaylistTitle": "Import Playlist",
|
||||||
"@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"},
|
"@dialogImportPlaylistTitle": {"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": {
|
||||||
@@ -1049,6 +1072,26 @@
|
|||||||
"@sectionAudioQuality": {"description": "Settings section header"},
|
"@sectionAudioQuality": {"description": "Settings section header"},
|
||||||
"sectionFileSettings": "File Settings",
|
"sectionFileSettings": "File Settings",
|
||||||
"@sectionFileSettings": {"description": "Settings section header"},
|
"@sectionFileSettings": {"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": {"description": "Settings section header"},
|
"@sectionColor": {"description": "Settings section header"},
|
||||||
"sectionTheme": "Theme",
|
"sectionTheme": "Theme",
|
||||||
@@ -1131,6 +1174,12 @@
|
|||||||
"@trackAudioQuality": {"description": "Metadata label - audio quality"},
|
"@trackAudioQuality": {"description": "Metadata label - audio quality"},
|
||||||
"trackReleaseDate": "Release date",
|
"trackReleaseDate": "Release date",
|
||||||
"@trackReleaseDate": {"description": "Metadata label - release date"},
|
"@trackReleaseDate": {"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": {"description": "Metadata label - download date"},
|
"@trackDownloaded": {"description": "Metadata label - download date"},
|
||||||
"trackCopyLyrics": "Copy lyrics",
|
"trackCopyLyrics": "Copy lyrics",
|
||||||
@@ -1141,6 +1190,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?",
|
||||||
@@ -1320,6 +1375,26 @@
|
|||||||
"@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"},
|
||||||
|
"qualityLossy": "Lossy",
|
||||||
|
"@qualityLossy": {"description": "Quality option - lossy format (MP3/Opus)"},
|
||||||
|
"qualityLossyMp3Subtitle": "MP3 320kbps (converted from FLAC)",
|
||||||
|
"@qualityLossyMp3Subtitle": {"description": "Technical spec for lossy MP3"},
|
||||||
|
"qualityLossyOpusSubtitle": "Opus 128kbps (converted from FLAC)",
|
||||||
|
"@qualityLossyOpusSubtitle": {"description": "Technical spec for lossy Opus"},
|
||||||
|
"enableLossyOption": "Enable Lossy Option",
|
||||||
|
"@enableLossyOption": {"description": "Setting - enable lossy quality option"},
|
||||||
|
"enableLossyOptionSubtitleOn": "Lossy quality option is available",
|
||||||
|
"@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"},
|
||||||
|
|
||||||
@@ -1420,6 +1495,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"},
|
||||||
@@ -1459,6 +1538,13 @@
|
|||||||
},
|
},
|
||||||
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
||||||
"@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"},
|
"@downloadedAlbumSelectToDelete": {"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": {"description": "Extension capability - utility functions"},
|
"@utilityFunctions": {"description": "Extension capability - utility functions"},
|
||||||
@@ -1485,5 +1571,95 @@
|
|||||||
"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"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +552,26 @@
|
|||||||
"@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"
|
||||||
@@ -642,6 +670,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artistPopular": "Popular",
|
||||||
|
"@artistPopular": {
|
||||||
|
"description": "Section header for popular/top tracks"
|
||||||
|
},
|
||||||
|
"artistMonthlyListeners": "{count} monthly listeners",
|
||||||
|
"@artistMonthlyListeners": {
|
||||||
|
"description": "Monthly listener count display",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Formatted listener count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Track Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
@@ -1108,6 +1150,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": {
|
||||||
@@ -1837,6 +1888,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"
|
||||||
@@ -1851,27 +1938,15 @@
|
|||||||
},
|
},
|
||||||
"sectionLanguage": "Language",
|
"sectionLanguage": "Language",
|
||||||
"@sectionLanguage": {
|
"@sectionLanguage": {
|
||||||
"description": "Settings section header for language selection"
|
"description": "Settings section header for language"
|
||||||
},
|
},
|
||||||
"appearanceLanguage": "App Language",
|
"appearanceLanguage": "App Language",
|
||||||
"@appearanceLanguage": {
|
"@appearanceLanguage": {
|
||||||
"description": "Setting title for language selection"
|
"description": "Language setting title"
|
||||||
},
|
},
|
||||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||||
"@appearanceLanguageSubtitle": {
|
"@appearanceLanguageSubtitle": {
|
||||||
"description": "Subtitle for language setting"
|
"description": "Language setting subtitle"
|
||||||
},
|
|
||||||
"languageSystem": "System Default",
|
|
||||||
"@languageSystem": {
|
|
||||||
"description": "Use device system language"
|
|
||||||
},
|
|
||||||
"languageEnglish": "English",
|
|
||||||
"@languageEnglish": {
|
|
||||||
"description": "English language option"
|
|
||||||
},
|
|
||||||
"languageIndonesian": "Bahasa Indonesia",
|
|
||||||
"@languageIndonesian": {
|
|
||||||
"description": "Indonesian language option"
|
|
||||||
},
|
},
|
||||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||||
"@settingsAppearanceSubtitle": {
|
"@settingsAppearanceSubtitle": {
|
||||||
@@ -1995,6 +2070,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"
|
||||||
@@ -2015,6 +2102,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"
|
||||||
@@ -2326,6 +2425,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"
|
||||||
@@ -2514,6 +2633,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"
|
||||||
@@ -2570,8 +2697,172 @@
|
|||||||
"@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"
|
||||||
|
},
|
||||||
|
"recentTypeArtist": "Artist",
|
||||||
|
"@recentTypeArtist": {
|
||||||
|
"description": "Recent access item type - artist"
|
||||||
|
},
|
||||||
|
"recentTypeAlbum": "Album",
|
||||||
|
"@recentTypeAlbum": {
|
||||||
|
"description": "Recent access item type - album"
|
||||||
|
},
|
||||||
|
"recentTypeSong": "Song",
|
||||||
|
"@recentTypeSong": {
|
||||||
|
"description": "Recent access item type - song/track"
|
||||||
|
},
|
||||||
|
"recentTypePlaylist": "Playlist",
|
||||||
|
"@recentTypePlaylist": {
|
||||||
|
"description": "Recent access item type - playlist"
|
||||||
|
},
|
||||||
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
|
"@recentPlaylistInfo": {
|
||||||
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Playlist name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorGeneric": "Error: {message}",
|
||||||
|
"@errorGeneric": {
|
||||||
|
"description": "Generic error message format",
|
||||||
|
"placeholders": {
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,6 +552,26 @@
|
|||||||
"@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"
|
||||||
@@ -642,6 +670,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artistPopular": "Popular",
|
||||||
|
"@artistPopular": {
|
||||||
|
"description": "Section header for popular/top tracks"
|
||||||
|
},
|
||||||
|
"artistMonthlyListeners": "{count} monthly listeners",
|
||||||
|
"@artistMonthlyListeners": {
|
||||||
|
"description": "Monthly listener count display",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Formatted listener count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Track Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
@@ -1108,6 +1150,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": {
|
||||||
@@ -1837,6 +1888,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"
|
||||||
@@ -1851,27 +1938,15 @@
|
|||||||
},
|
},
|
||||||
"sectionLanguage": "Language",
|
"sectionLanguage": "Language",
|
||||||
"@sectionLanguage": {
|
"@sectionLanguage": {
|
||||||
"description": "Settings section header for language selection"
|
"description": "Settings section header for language"
|
||||||
},
|
},
|
||||||
"appearanceLanguage": "App Language",
|
"appearanceLanguage": "App Language",
|
||||||
"@appearanceLanguage": {
|
"@appearanceLanguage": {
|
||||||
"description": "Setting title for language selection"
|
"description": "Language setting title"
|
||||||
},
|
},
|
||||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||||
"@appearanceLanguageSubtitle": {
|
"@appearanceLanguageSubtitle": {
|
||||||
"description": "Subtitle for language setting"
|
"description": "Language setting subtitle"
|
||||||
},
|
|
||||||
"languageSystem": "System Default",
|
|
||||||
"@languageSystem": {
|
|
||||||
"description": "Use device system language"
|
|
||||||
},
|
|
||||||
"languageEnglish": "English",
|
|
||||||
"@languageEnglish": {
|
|
||||||
"description": "English language option"
|
|
||||||
},
|
|
||||||
"languageIndonesian": "Bahasa Indonesia",
|
|
||||||
"@languageIndonesian": {
|
|
||||||
"description": "Indonesian language option"
|
|
||||||
},
|
},
|
||||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||||
"@settingsAppearanceSubtitle": {
|
"@settingsAppearanceSubtitle": {
|
||||||
@@ -1995,6 +2070,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"
|
||||||
@@ -2015,6 +2102,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"
|
||||||
@@ -2326,6 +2425,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"
|
||||||
@@ -2514,6 +2633,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"
|
||||||
@@ -2570,8 +2697,172 @@
|
|||||||
"@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"
|
||||||
|
},
|
||||||
|
"recentTypeArtist": "Artist",
|
||||||
|
"@recentTypeArtist": {
|
||||||
|
"description": "Recent access item type - artist"
|
||||||
|
},
|
||||||
|
"recentTypeAlbum": "Album",
|
||||||
|
"@recentTypeAlbum": {
|
||||||
|
"description": "Recent access item type - album"
|
||||||
|
},
|
||||||
|
"recentTypeSong": "Song",
|
||||||
|
"@recentTypeSong": {
|
||||||
|
"description": "Recent access item type - song/track"
|
||||||
|
},
|
||||||
|
"recentTypePlaylist": "Playlist",
|
||||||
|
"@recentTypePlaylist": {
|
||||||
|
"description": "Recent access item type - playlist"
|
||||||
|
},
|
||||||
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
|
"@recentPlaylistInfo": {
|
||||||
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Playlist name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorGeneric": "Error: {message}",
|
||||||
|
"@errorGeneric": {
|
||||||
|
"description": "Generic error message format",
|
||||||
|
"placeholders": {
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,6 +552,26 @@
|
|||||||
"@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"
|
||||||
@@ -642,6 +670,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artistPopular": "Popular",
|
||||||
|
"@artistPopular": {
|
||||||
|
"description": "Section header for popular/top tracks"
|
||||||
|
},
|
||||||
|
"artistMonthlyListeners": "{count} monthly listeners",
|
||||||
|
"@artistMonthlyListeners": {
|
||||||
|
"description": "Monthly listener count display",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Formatted listener count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Track Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
@@ -1108,6 +1150,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": {
|
||||||
@@ -1837,6 +1888,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"
|
||||||
@@ -1851,27 +1938,15 @@
|
|||||||
},
|
},
|
||||||
"sectionLanguage": "Language",
|
"sectionLanguage": "Language",
|
||||||
"@sectionLanguage": {
|
"@sectionLanguage": {
|
||||||
"description": "Settings section header for language selection"
|
"description": "Settings section header for language"
|
||||||
},
|
},
|
||||||
"appearanceLanguage": "App Language",
|
"appearanceLanguage": "App Language",
|
||||||
"@appearanceLanguage": {
|
"@appearanceLanguage": {
|
||||||
"description": "Setting title for language selection"
|
"description": "Language setting title"
|
||||||
},
|
},
|
||||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||||
"@appearanceLanguageSubtitle": {
|
"@appearanceLanguageSubtitle": {
|
||||||
"description": "Subtitle for language setting"
|
"description": "Language setting subtitle"
|
||||||
},
|
|
||||||
"languageSystem": "System Default",
|
|
||||||
"@languageSystem": {
|
|
||||||
"description": "Use device system language"
|
|
||||||
},
|
|
||||||
"languageEnglish": "English",
|
|
||||||
"@languageEnglish": {
|
|
||||||
"description": "English language option"
|
|
||||||
},
|
|
||||||
"languageIndonesian": "Bahasa Indonesia",
|
|
||||||
"@languageIndonesian": {
|
|
||||||
"description": "Indonesian language option"
|
|
||||||
},
|
},
|
||||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||||
"@settingsAppearanceSubtitle": {
|
"@settingsAppearanceSubtitle": {
|
||||||
@@ -1995,6 +2070,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"
|
||||||
@@ -2015,6 +2102,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"
|
||||||
@@ -2326,6 +2425,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"
|
||||||
@@ -2514,6 +2633,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"
|
||||||
@@ -2570,8 +2697,172 @@
|
|||||||
"@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"
|
||||||
|
},
|
||||||
|
"recentTypeArtist": "Artist",
|
||||||
|
"@recentTypeArtist": {
|
||||||
|
"description": "Recent access item type - artist"
|
||||||
|
},
|
||||||
|
"recentTypeAlbum": "Album",
|
||||||
|
"@recentTypeAlbum": {
|
||||||
|
"description": "Recent access item type - album"
|
||||||
|
},
|
||||||
|
"recentTypeSong": "Song",
|
||||||
|
"@recentTypeSong": {
|
||||||
|
"description": "Recent access item type - song/track"
|
||||||
|
},
|
||||||
|
"recentTypePlaylist": "Playlist",
|
||||||
|
"@recentTypePlaylist": {
|
||||||
|
"description": "Recent access item type - playlist"
|
||||||
|
},
|
||||||
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
|
"@recentPlaylistInfo": {
|
||||||
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Playlist name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorGeneric": "Error: {message}",
|
||||||
|
"@errorGeneric": {
|
||||||
|
"description": "Generic error message format",
|
||||||
|
"placeholders": {
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,6 +552,26 @@
|
|||||||
"@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"
|
||||||
@@ -642,6 +670,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artistPopular": "Popular",
|
||||||
|
"@artistPopular": {
|
||||||
|
"description": "Section header for popular/top tracks"
|
||||||
|
},
|
||||||
|
"artistMonthlyListeners": "{count} monthly listeners",
|
||||||
|
"@artistMonthlyListeners": {
|
||||||
|
"description": "Monthly listener count display",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Formatted listener count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Track Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
@@ -1108,6 +1150,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": {
|
||||||
@@ -1837,6 +1888,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"
|
||||||
@@ -1851,27 +1938,15 @@
|
|||||||
},
|
},
|
||||||
"sectionLanguage": "Language",
|
"sectionLanguage": "Language",
|
||||||
"@sectionLanguage": {
|
"@sectionLanguage": {
|
||||||
"description": "Settings section header for language selection"
|
"description": "Settings section header for language"
|
||||||
},
|
},
|
||||||
"appearanceLanguage": "App Language",
|
"appearanceLanguage": "App Language",
|
||||||
"@appearanceLanguage": {
|
"@appearanceLanguage": {
|
||||||
"description": "Setting title for language selection"
|
"description": "Language setting title"
|
||||||
},
|
},
|
||||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||||
"@appearanceLanguageSubtitle": {
|
"@appearanceLanguageSubtitle": {
|
||||||
"description": "Subtitle for language setting"
|
"description": "Language setting subtitle"
|
||||||
},
|
|
||||||
"languageSystem": "System Default",
|
|
||||||
"@languageSystem": {
|
|
||||||
"description": "Use device system language"
|
|
||||||
},
|
|
||||||
"languageEnglish": "English",
|
|
||||||
"@languageEnglish": {
|
|
||||||
"description": "English language option"
|
|
||||||
},
|
|
||||||
"languageIndonesian": "Bahasa Indonesia",
|
|
||||||
"@languageIndonesian": {
|
|
||||||
"description": "Indonesian language option"
|
|
||||||
},
|
},
|
||||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||||
"@settingsAppearanceSubtitle": {
|
"@settingsAppearanceSubtitle": {
|
||||||
@@ -1995,6 +2070,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"
|
||||||
@@ -2015,6 +2102,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"
|
||||||
@@ -2326,6 +2425,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"
|
||||||
@@ -2514,6 +2633,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"
|
||||||
@@ -2570,8 +2697,172 @@
|
|||||||
"@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"
|
||||||
|
},
|
||||||
|
"recentTypeArtist": "Artist",
|
||||||
|
"@recentTypeArtist": {
|
||||||
|
"description": "Recent access item type - artist"
|
||||||
|
},
|
||||||
|
"recentTypeAlbum": "Album",
|
||||||
|
"@recentTypeAlbum": {
|
||||||
|
"description": "Recent access item type - album"
|
||||||
|
},
|
||||||
|
"recentTypeSong": "Song",
|
||||||
|
"@recentTypeSong": {
|
||||||
|
"description": "Recent access item type - song/track"
|
||||||
|
},
|
||||||
|
"recentTypePlaylist": "Playlist",
|
||||||
|
"@recentTypePlaylist": {
|
||||||
|
"description": "Recent access item type - playlist"
|
||||||
|
},
|
||||||
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
|
"@recentPlaylistInfo": {
|
||||||
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Playlist name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorGeneric": "Error: {message}",
|
||||||
|
"@errorGeneric": {
|
||||||
|
"description": "Generic error message format",
|
||||||
|
"placeholders": {
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,6 +552,26 @@
|
|||||||
"@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"
|
||||||
@@ -642,6 +670,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artistPopular": "Popular",
|
||||||
|
"@artistPopular": {
|
||||||
|
"description": "Section header for popular/top tracks"
|
||||||
|
},
|
||||||
|
"artistMonthlyListeners": "{count} monthly listeners",
|
||||||
|
"@artistMonthlyListeners": {
|
||||||
|
"description": "Monthly listener count display",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Formatted listener count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Track Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
@@ -1108,6 +1150,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": {
|
||||||
@@ -1837,6 +1888,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"
|
||||||
@@ -1851,27 +1938,15 @@
|
|||||||
},
|
},
|
||||||
"sectionLanguage": "Language",
|
"sectionLanguage": "Language",
|
||||||
"@sectionLanguage": {
|
"@sectionLanguage": {
|
||||||
"description": "Settings section header for language selection"
|
"description": "Settings section header for language"
|
||||||
},
|
},
|
||||||
"appearanceLanguage": "App Language",
|
"appearanceLanguage": "App Language",
|
||||||
"@appearanceLanguage": {
|
"@appearanceLanguage": {
|
||||||
"description": "Setting title for language selection"
|
"description": "Language setting title"
|
||||||
},
|
},
|
||||||
"appearanceLanguageSubtitle": "Choose your preferred language",
|
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||||
"@appearanceLanguageSubtitle": {
|
"@appearanceLanguageSubtitle": {
|
||||||
"description": "Subtitle for language setting"
|
"description": "Language setting subtitle"
|
||||||
},
|
|
||||||
"languageSystem": "System Default",
|
|
||||||
"@languageSystem": {
|
|
||||||
"description": "Use device system language"
|
|
||||||
},
|
|
||||||
"languageEnglish": "English",
|
|
||||||
"@languageEnglish": {
|
|
||||||
"description": "English language option"
|
|
||||||
},
|
|
||||||
"languageIndonesian": "Bahasa Indonesia",
|
|
||||||
"@languageIndonesian": {
|
|
||||||
"description": "Indonesian language option"
|
|
||||||
},
|
},
|
||||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||||
"@settingsAppearanceSubtitle": {
|
"@settingsAppearanceSubtitle": {
|
||||||
@@ -1995,6 +2070,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"
|
||||||
@@ -2015,6 +2102,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"
|
||||||
@@ -2326,6 +2425,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"
|
||||||
@@ -2514,6 +2633,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"
|
||||||
@@ -2570,8 +2697,172 @@
|
|||||||
"@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"
|
||||||
|
},
|
||||||
|
"recentTypeArtist": "Artist",
|
||||||
|
"@recentTypeArtist": {
|
||||||
|
"description": "Recent access item type - artist"
|
||||||
|
},
|
||||||
|
"recentTypeAlbum": "Album",
|
||||||
|
"@recentTypeAlbum": {
|
||||||
|
"description": "Recent access item type - album"
|
||||||
|
},
|
||||||
|
"recentTypeSong": "Song",
|
||||||
|
"@recentTypeSong": {
|
||||||
|
"description": "Recent access item type - song/track"
|
||||||
|
},
|
||||||
|
"recentTypePlaylist": "Playlist",
|
||||||
|
"@recentTypePlaylist": {
|
||||||
|
"description": "Recent access item type - playlist"
|
||||||
|
},
|
||||||
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
|
"@recentPlaylistInfo": {
|
||||||
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Playlist name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorGeneric": "Error: {message}",
|
||||||
|
"@errorGeneric": {
|
||||||
|
"description": "Generic error message format",
|
||||||
|
"placeholders": {
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"@homeSupports": {
|
"@homeSupports": {
|
||||||
"description": "Info text about supported URL types"
|
"description": "Info text about supported URL types"
|
||||||
},
|
},
|
||||||
"homeRecent": "Recent",
|
"homeRecent": "最新的",
|
||||||
"@homeRecent": {
|
"@homeRecent": {
|
||||||
"description": "Section header for recent searches"
|
"description": "Section header for recent searches"
|
||||||
},
|
},
|
||||||
@@ -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,6 +552,26 @@
|
|||||||
"@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"
|
||||||
@@ -642,6 +670,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"artistPopular": "Popular",
|
||||||
|
"@artistPopular": {
|
||||||
|
"description": "Section header for popular/top tracks"
|
||||||
|
},
|
||||||
|
"artistMonthlyListeners": "{count} monthly listeners",
|
||||||
|
"@artistMonthlyListeners": {
|
||||||
|
"description": "Monthly listener count display",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Formatted listener count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackMetadataTitle": "Track Info",
|
"trackMetadataTitle": "Track Info",
|
||||||
"@trackMetadataTitle": {
|
"@trackMetadataTitle": {
|
||||||
"description": "Track metadata screen title"
|
"description": "Track metadata screen title"
|
||||||
@@ -1108,6 +1150,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": {
|
||||||
@@ -1837,6 +1888,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"
|
||||||
@@ -1849,6 +1936,18 @@
|
|||||||
"@sectionLayout": {
|
"@sectionLayout": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
|
"sectionLanguage": "Language",
|
||||||
|
"@sectionLanguage": {
|
||||||
|
"description": "Settings section header for language"
|
||||||
|
},
|
||||||
|
"appearanceLanguage": "App Language",
|
||||||
|
"@appearanceLanguage": {
|
||||||
|
"description": "Language setting title"
|
||||||
|
},
|
||||||
|
"appearanceLanguageSubtitle": "Choose your preferred language",
|
||||||
|
"@appearanceLanguageSubtitle": {
|
||||||
|
"description": "Language setting subtitle"
|
||||||
|
},
|
||||||
"settingsAppearanceSubtitle": "Theme, colors, display",
|
"settingsAppearanceSubtitle": "Theme, colors, display",
|
||||||
"@settingsAppearanceSubtitle": {
|
"@settingsAppearanceSubtitle": {
|
||||||
"description": "Appearance settings description"
|
"description": "Appearance settings description"
|
||||||
@@ -1971,6 +2070,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"
|
||||||
@@ -1991,6 +2102,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"
|
||||||
@@ -2302,6 +2425,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"
|
||||||
@@ -2490,6 +2633,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"
|
||||||
@@ -2546,8 +2697,172 @@
|
|||||||
"@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"
|
||||||
|
},
|
||||||
|
"recentTypeArtist": "Artist",
|
||||||
|
"@recentTypeArtist": {
|
||||||
|
"description": "Recent access item type - artist"
|
||||||
|
},
|
||||||
|
"recentTypeAlbum": "Album",
|
||||||
|
"@recentTypeAlbum": {
|
||||||
|
"description": "Recent access item type - album"
|
||||||
|
},
|
||||||
|
"recentTypeSong": "Song",
|
||||||
|
"@recentTypeSong": {
|
||||||
|
"description": "Recent access item type - song/track"
|
||||||
|
},
|
||||||
|
"recentTypePlaylist": "Playlist",
|
||||||
|
"@recentTypePlaylist": {
|
||||||
|
"description": "Recent access item type - playlist"
|
||||||
|
},
|
||||||
|
"recentPlaylistInfo": "Playlist: {name}",
|
||||||
|
"@recentPlaylistInfo": {
|
||||||
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"description": "Playlist name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorGeneric": "Error: {message}",
|
||||||
|
"@errorGeneric": {
|
||||||
|
"description": "Generic error message format",
|
||||||
|
"placeholders": {
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,11 +14,21 @@ const int translationThreshold = 70;
|
|||||||
/// Only these languages will be available in the app.
|
/// Only these languages will be available in the app.
|
||||||
const List<Locale> filteredSupportedLocales = <Locale>[
|
const List<Locale> filteredSupportedLocales = <Locale>[
|
||||||
Locale('en'),
|
Locale('en'),
|
||||||
|
Locale('ru'),
|
||||||
|
Locale('es', 'ES'),
|
||||||
Locale('id'),
|
Locale('id'),
|
||||||
|
Locale('pt', 'PT'),
|
||||||
|
Locale('ja'),
|
||||||
|
Locale('tr'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Set of locale codes for quick lookup.
|
/// Set of locale codes for quick lookup.
|
||||||
const Set<String> filteredLocaleCodes = <String>{
|
const Set<String> filteredLocaleCodes = <String>{
|
||||||
'en',
|
'en',
|
||||||
|
'ru',
|
||||||
|
'es_ES',
|
||||||
'id',
|
'id',
|
||||||
|
'pt_PT',
|
||||||
|
'ja',
|
||||||
|
'tr',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// Initialize notification service
|
await CoverCacheManager.initialize();
|
||||||
await NotificationService().initialize();
|
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
|
||||||
|
|
||||||
// Initialize share intent service
|
await Future.wait([
|
||||||
await ShareIntentService().initialize();
|
NotificationService().initialize(),
|
||||||
|
ShareIntentService().initialize(),
|
||||||
|
]);
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
@@ -40,6 +43,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initializeExtensions();
|
_initializeExtensions();
|
||||||
|
// Trigger history provider initialization without subscribing to updates.
|
||||||
|
ref.read(downloadHistoryProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeExtensions() async {
|
Future<void> _initializeExtensions() async {
|
||||||
@@ -48,11 +53,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
final extensionsDir = '${appDir.path}/extensions';
|
final extensionsDir = '${appDir.path}/extensions';
|
||||||
final dataDir = '${appDir.path}/extension_data';
|
final dataDir = '${appDir.path}/extension_data';
|
||||||
|
|
||||||
// Create directories if needed
|
|
||||||
await Directory(extensionsDir).create(recursive: true);
|
await Directory(extensionsDir).create(recursive: true);
|
||||||
await Directory(dataDir).create(recursive: true);
|
await Directory(dataDir).create(recursive: true);
|
||||||
|
|
||||||
// Initialize extension system
|
|
||||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to initialize extensions: $e');
|
debugPrint('Failed to initialize extensions: $e');
|
||||||
@@ -61,8 +64,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Eagerly initialize download history provider to load from storage
|
|
||||||
ref.watch(downloadHistoryProvider);
|
|
||||||
return widget.child;
|
return widget.child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,21 @@ import 'package:spotiflac_android/models/track.dart';
|
|||||||
|
|
||||||
part 'download_item.g.dart';
|
part 'download_item.g.dart';
|
||||||
|
|
||||||
/// Download status enum
|
|
||||||
enum DownloadStatus {
|
enum DownloadStatus {
|
||||||
queued,
|
queued,
|
||||||
downloading,
|
downloading,
|
||||||
finalizing, // Embedding metadata, cover, lyrics
|
finalizing,
|
||||||
completed,
|
completed,
|
||||||
failed,
|
failed,
|
||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error type enum for better error handling
|
|
||||||
enum DownloadErrorType {
|
enum DownloadErrorType {
|
||||||
unknown,
|
unknown,
|
||||||
notFound, // Track not found on any service
|
notFound,
|
||||||
rateLimit, // Rate limited by service
|
rateLimit,
|
||||||
network, // Network/connection error
|
network,
|
||||||
permission, // File/folder permission error
|
permission,
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
@@ -29,7 +27,7 @@ class DownloadItem {
|
|||||||
final String service;
|
final String service;
|
||||||
final DownloadStatus status;
|
final DownloadStatus status;
|
||||||
final double progress;
|
final double progress;
|
||||||
final double speedMBps; // Download speed in MB/s
|
final double speedMBps;
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
final DownloadErrorType? errorType;
|
final DownloadErrorType? errorType;
|
||||||
@@ -78,7 +76,6 @@ class DownloadItem {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get user-friendly error message based on error type
|
|
||||||
String get errorMessage {
|
String get errorMessage {
|
||||||
if (error == null) return '';
|
if (error == null) return '';
|
||||||
|
|
||||||
|
|||||||
@@ -12,25 +12,30 @@ class AppSettings {
|
|||||||
final bool embedLyrics;
|
final bool embedLyrics;
|
||||||
final bool maxQualityCover;
|
final bool maxQualityCover;
|
||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
final int concurrentDownloads; // 1 = sequential (default), max 3
|
final int concurrentDownloads;
|
||||||
final bool checkForUpdates; // Check for updates on app start
|
final bool checkForUpdates;
|
||||||
final String updateChannel; // stable, preview
|
final String updateChannel;
|
||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore;
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization;
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode;
|
||||||
final String historyFilterMode; // all, albums, singles
|
final String historyFilterMode;
|
||||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
final bool askQualityBeforeDownload;
|
||||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
final String spotifyClientId;
|
||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret;
|
||||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
final bool useCustomSpotifyCredentials;
|
||||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
final String metadataSource;
|
||||||
final bool enableLogging; // Enable detailed logging for debugging
|
final bool enableLogging;
|
||||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
final bool useExtensionProviders;
|
||||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
final String? searchProvider;
|
||||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
final bool separateSingles;
|
||||||
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
|
final String albumFolderStructure;
|
||||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
final bool showExtensionStore;
|
||||||
final String locale; // App language: 'system', 'en', 'id', etc.
|
final String locale;
|
||||||
|
final bool enableLossyOption;
|
||||||
|
final String lossyFormat;
|
||||||
|
final String lossyBitrate; // e.g., 'mp3_320', 'mp3_256', 'mp3_192', 'mp3_128', 'opus_128', 'opus_96', 'opus_64'
|
||||||
|
final String lyricsMode;
|
||||||
|
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -41,25 +46,30 @@ class AppSettings {
|
|||||||
this.embedLyrics = true,
|
this.embedLyrics = true,
|
||||||
this.maxQualityCover = true,
|
this.maxQualityCover = true,
|
||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
this.concurrentDownloads = 1,
|
||||||
this.checkForUpdates = true, // Default: enabled
|
this.checkForUpdates = true,
|
||||||
this.updateChannel = 'stable', // Default: stable releases only
|
this.updateChannel = 'stable',
|
||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false,
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none',
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid',
|
||||||
this.historyFilterMode = 'all', // Default: show all
|
this.historyFilterMode = 'all',
|
||||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
this.askQualityBeforeDownload = true,
|
||||||
this.spotifyClientId = '', // Default: use built-in credentials
|
this.spotifyClientId = '',
|
||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '',
|
||||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
this.useCustomSpotifyCredentials = true,
|
||||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
this.metadataSource = 'deezer',
|
||||||
this.enableLogging = false, // Default: disabled for performance
|
this.enableLogging = false,
|
||||||
this.useExtensionProviders = true, // Default: use extensions when available
|
this.useExtensionProviders = true,
|
||||||
this.searchProvider, // Default: null (use Deezer/Spotify)
|
this.searchProvider,
|
||||||
this.separateSingles = false, // Default: disabled
|
this.separateSingles = false,
|
||||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
this.albumFolderStructure = 'artist_album',
|
||||||
this.showExtensionStore = true, // Default: show store
|
this.showExtensionStore = true,
|
||||||
this.locale = 'system', // Default: follow system language
|
this.locale = 'system',
|
||||||
|
this.enableLossyOption = false,
|
||||||
|
this.lossyFormat = 'mp3',
|
||||||
|
this.lossyBitrate = 'mp3_320',
|
||||||
|
this.lyricsMode = 'embed',
|
||||||
|
this.useAllFilesAccess = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -86,11 +96,16 @@ class AppSettings {
|
|||||||
bool? enableLogging,
|
bool? enableLogging,
|
||||||
bool? useExtensionProviders,
|
bool? useExtensionProviders,
|
||||||
String? searchProvider,
|
String? searchProvider,
|
||||||
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
|
bool clearSearchProvider = false,
|
||||||
bool? separateSingles,
|
bool? separateSingles,
|
||||||
String? albumFolderStructure,
|
String? albumFolderStructure,
|
||||||
bool? showExtensionStore,
|
bool? showExtensionStore,
|
||||||
String? locale,
|
String? locale,
|
||||||
|
bool? enableLossyOption,
|
||||||
|
String? lossyFormat,
|
||||||
|
String? lossyBitrate,
|
||||||
|
String? lyricsMode,
|
||||||
|
bool? useAllFilesAccess,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -120,6 +135,11 @@ class AppSettings {
|
|||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
|
enableLossyOption: enableLossyOption ?? this.enableLossyOption,
|
||||||
|
lossyFormat: lossyFormat ?? this.lossyFormat,
|
||||||
|
lossyBitrate: lossyBitrate ?? this.lossyBitrate,
|
||||||
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||||
locale: json['locale'] as String? ?? 'system',
|
locale: json['locale'] as String? ?? 'system',
|
||||||
|
enableLossyOption: json['enableLossyOption'] as bool? ?? false,
|
||||||
|
lossyFormat: json['lossyFormat'] as String? ?? 'mp3',
|
||||||
|
lossyBitrate: json['lossyBitrate'] as String? ?? 'mp3_320',
|
||||||
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
|
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -67,4 +72,9 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
|
'enableLossyOption': instance.enableLossyOption,
|
||||||
|
'lossyFormat': instance.lossyFormat,
|
||||||
|
'lossyBitrate': instance.lossyBitrate,
|
||||||
|
'lyricsMode': instance.lyricsMode,
|
||||||
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const String kUseAmoledKey = 'use_amoled';
|
|||||||
/// Default Spotify green color for fallback
|
/// Default Spotify green color for fallback
|
||||||
const int kDefaultSeedColor = 0xFF1DB954;
|
const int kDefaultSeedColor = 0xFF1DB954;
|
||||||
|
|
||||||
/// Theme settings model for Material Expressive 3
|
|
||||||
class ThemeSettings {
|
class ThemeSettings {
|
||||||
final ThemeMode themeMode;
|
final ThemeMode themeMode;
|
||||||
final bool useDynamicColor;
|
final bool useDynamicColor;
|
||||||
@@ -23,10 +22,8 @@ class ThemeSettings {
|
|||||||
this.useAmoled = false,
|
this.useAmoled = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Get seed color as Color object
|
|
||||||
Color get seedColor => Color(seedColorValue);
|
Color get seedColor => Color(seedColorValue);
|
||||||
|
|
||||||
/// Create a copy with updated values
|
|
||||||
ThemeSettings copyWith({
|
ThemeSettings copyWith({
|
||||||
ThemeMode? themeMode,
|
ThemeMode? themeMode,
|
||||||
bool? useDynamicColor,
|
bool? useDynamicColor,
|
||||||
@@ -41,7 +38,6 @@ class ThemeSettings {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert to JSON map for persistence
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
kThemeModeKey: themeMode.name,
|
kThemeModeKey: themeMode.name,
|
||||||
kUseDynamicColorKey: useDynamicColor,
|
kUseDynamicColorKey: useDynamicColor,
|
||||||
@@ -49,7 +45,6 @@ class ThemeSettings {
|
|||||||
kUseAmoledKey: useAmoled,
|
kUseAmoledKey: useAmoled,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Create from JSON map
|
|
||||||
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
|
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
|
||||||
return ThemeSettings(
|
return ThemeSettings(
|
||||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||||
@@ -74,7 +69,6 @@ class ThemeSettings {
|
|||||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to convert string to ThemeMode
|
|
||||||
ThemeMode _themeModeFromString(String? value) {
|
ThemeMode _themeModeFromString(String? value) {
|
||||||
if (value == null) return ThemeMode.system;
|
if (value == null) return ThemeMode.system;
|
||||||
return ThemeMode.values.firstWhere(
|
return ThemeMode.values.firstWhere(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
|||||||
|
|
||||||
part 'track.g.dart';
|
part 'track.g.dart';
|
||||||
|
|
||||||
/// Track model representing a music track
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class Track {
|
class Track {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -18,9 +17,9 @@ class Track {
|
|||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
final String? deezerId;
|
final String? deezerId;
|
||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
final String? source;
|
||||||
final String? albumType; // album, single, ep, compilation (from metadata API)
|
final String? albumType;
|
||||||
final String? itemType; // track, album, playlist - for extension search results
|
final String? itemType;
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -41,25 +40,19 @@ class Track {
|
|||||||
this.itemType,
|
this.itemType,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Check if this track is a single (based on album_type metadata)
|
|
||||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
||||||
|
|
||||||
/// Check if this is an album item (not a track)
|
|
||||||
bool get isAlbumItem => itemType == 'album';
|
bool get isAlbumItem => itemType == 'album';
|
||||||
|
|
||||||
/// Check if this is a playlist item (not a track)
|
|
||||||
bool get isPlaylistItem => itemType == 'playlist';
|
bool get isPlaylistItem => itemType == 'playlist';
|
||||||
|
|
||||||
/// Check if this is an artist item (not a track)
|
|
||||||
bool get isArtistItem => itemType == 'artist';
|
bool get isArtistItem => itemType == 'artist';
|
||||||
|
|
||||||
/// Check if this is a collection (album, playlist, or artist)
|
|
||||||
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
|
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
|
||||||
|
|
||||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||||
|
|
||||||
/// Check if this track is from an extension
|
|
||||||
bool get isFromExtension => source != null && source!.isNotEmpty;
|
bool get isFromExtension => source != null && source!.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('ExploreProvider');
|
||||||
|
|
||||||
|
/// Represents an item in a Spotify home section
|
||||||
|
class ExploreItem {
|
||||||
|
final String id;
|
||||||
|
final String uri;
|
||||||
|
final String type; // track, album, playlist, artist, station
|
||||||
|
final String name;
|
||||||
|
final String artists;
|
||||||
|
final String? description;
|
||||||
|
final String? coverUrl;
|
||||||
|
final String? providerId;
|
||||||
|
final String? albumId;
|
||||||
|
final String? albumName;
|
||||||
|
final int durationMs;
|
||||||
|
|
||||||
|
const ExploreItem({
|
||||||
|
required this.id,
|
||||||
|
required this.uri,
|
||||||
|
required this.type,
|
||||||
|
required this.name,
|
||||||
|
required this.artists,
|
||||||
|
this.description,
|
||||||
|
this.coverUrl,
|
||||||
|
this.providerId,
|
||||||
|
this.albumId,
|
||||||
|
this.albumName,
|
||||||
|
this.durationMs = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExploreItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ExploreItem(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
uri: json['uri'] as String? ?? '',
|
||||||
|
type: json['type'] as String? ?? 'track',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
artists: json['artists'] as String? ?? '',
|
||||||
|
description: json['description'] as String?,
|
||||||
|
coverUrl: json['cover_url'] as String?,
|
||||||
|
providerId: json['provider_id'] as String?,
|
||||||
|
albumId: json['album_id'] as String?,
|
||||||
|
albumName: json['album_name'] as String?,
|
||||||
|
durationMs: json['duration_ms'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a section in Spotify home feed
|
||||||
|
class ExploreSection {
|
||||||
|
final String uri;
|
||||||
|
final String title;
|
||||||
|
final List<ExploreItem> items;
|
||||||
|
final bool isYTMusicQuickPicks;
|
||||||
|
|
||||||
|
const ExploreSection({
|
||||||
|
required this.uri,
|
||||||
|
required this.title,
|
||||||
|
required this.items,
|
||||||
|
this.isYTMusicQuickPicks = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExploreSection.fromJson(Map<String, dynamic> json) {
|
||||||
|
final itemsList = json['items'] as List<dynamic>? ?? [];
|
||||||
|
final items = itemsList
|
||||||
|
.map((item) => ExploreItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
final isQuickPicks = _isYTMusicQuickPicksItems(items);
|
||||||
|
return ExploreSection(
|
||||||
|
uri: json['uri'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
items: items,
|
||||||
|
isYTMusicQuickPicks: isQuickPicks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for explore/home feed
|
||||||
|
class ExploreState {
|
||||||
|
final bool isLoading;
|
||||||
|
final String? error;
|
||||||
|
final String? greeting;
|
||||||
|
final List<ExploreSection> sections;
|
||||||
|
final DateTime? lastFetched;
|
||||||
|
|
||||||
|
const ExploreState({
|
||||||
|
this.isLoading = false,
|
||||||
|
this.error,
|
||||||
|
this.greeting,
|
||||||
|
this.sections = const [],
|
||||||
|
this.lastFetched,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasContent => sections.isNotEmpty;
|
||||||
|
|
||||||
|
ExploreState copyWith({
|
||||||
|
bool? isLoading,
|
||||||
|
String? error,
|
||||||
|
String? greeting,
|
||||||
|
List<ExploreSection>? sections,
|
||||||
|
DateTime? lastFetched,
|
||||||
|
}) {
|
||||||
|
return ExploreState(
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
error: error,
|
||||||
|
greeting: greeting ?? this.greeting,
|
||||||
|
sections: sections ?? this.sections,
|
||||||
|
lastFetched: lastFetched ?? this.lastFetched,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate greeting based on local device time
|
||||||
|
String _getLocalGreeting() {
|
||||||
|
final hour = DateTime.now().hour;
|
||||||
|
if (hour >= 5 && hour < 12) {
|
||||||
|
return 'Good morning';
|
||||||
|
} else if (hour >= 12 && hour < 17) {
|
||||||
|
return 'Good afternoon';
|
||||||
|
} else if (hour >= 17 && hour < 21) {
|
||||||
|
return 'Good evening';
|
||||||
|
} else {
|
||||||
|
return 'Good night';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
|
||||||
|
if (items.isEmpty) return false;
|
||||||
|
if (items.first.providerId != 'ytmusic-spotiflac') return false;
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.type != 'track') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider for explore/home feed state
|
||||||
|
class ExploreNotifier extends Notifier<ExploreState> {
|
||||||
|
@override
|
||||||
|
ExploreState build() {
|
||||||
|
return const ExploreState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch home feed from spotify-web extension
|
||||||
|
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||||
|
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||||
|
|
||||||
|
// Don't refetch if we have data and it's less than 5 minutes old
|
||||||
|
if (!forceRefresh &&
|
||||||
|
state.hasContent &&
|
||||||
|
state.lastFetched != null &&
|
||||||
|
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||||
|
_log.d('Using cached home feed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
_log.d('Home feed fetch already in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find any extension with homeFeed capability
|
||||||
|
final extState = ref.read(extensionProvider);
|
||||||
|
_log.d('Extensions count: ${extState.extensions.length}');
|
||||||
|
|
||||||
|
// Look for extensions with homeFeed capability (prefer spotify-web)
|
||||||
|
Extension? targetExt;
|
||||||
|
for (final extension in extState.extensions) {
|
||||||
|
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (targetExt == null || extension.id == 'spotify-web') {
|
||||||
|
targetExt = extension;
|
||||||
|
if (extension.id == 'spotify-web') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetExt == null) {
|
||||||
|
_log.w('No extension with homeFeed capability found');
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: 'No extension with home feed support enabled',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Fetching home feed from ${targetExt.id}...');
|
||||||
|
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Failed to fetch home feed',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = result['success'] as bool? ?? false;
|
||||||
|
_log.d('getExtensionHomeFeed success=$success');
|
||||||
|
if (!success) {
|
||||||
|
final error = result['error'] as String? ?? 'Unknown error';
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final greeting = result['greeting'] as String?;
|
||||||
|
final sectionsData = result['sections'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
final sections = sectionsData
|
||||||
|
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_log.i('Fetched ${sections.length} sections');
|
||||||
|
|
||||||
|
// Debug: log first section items
|
||||||
|
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||||
|
final firstItem = sections.first.items.first;
|
||||||
|
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always use local device time for greeting to avoid timezone issues
|
||||||
|
// Extension greeting may use wrong timezone (UTC or Spotify account timezone)
|
||||||
|
final localGreeting = _getLocalGreeting();
|
||||||
|
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
|
||||||
|
|
||||||
|
state = ExploreState(
|
||||||
|
isLoading: false,
|
||||||
|
greeting: localGreeting,
|
||||||
|
sections: sections,
|
||||||
|
lastFetched: DateTime.now(),
|
||||||
|
);
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log.e('Error fetching home feed: $e', e, stack);
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
error: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear cached data
|
||||||
|
void clear() {
|
||||||
|
state = const ExploreState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh home feed
|
||||||
|
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||||
|
return ExploreNotifier();
|
||||||
|
});
|
||||||
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
|
|
||||||
final _log = AppLogger('ExtensionProvider');
|
final _log = AppLogger('ExtensionProvider');
|
||||||
|
|
||||||
/// Represents an installed extension
|
|
||||||
class Extension {
|
class Extension {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -14,19 +13,20 @@ class Extension {
|
|||||||
final String author;
|
final String author;
|
||||||
final String description;
|
final String description;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final String status; // 'loaded', 'error', 'disabled'
|
final String status;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? iconPath; // Path to extension icon
|
final String? iconPath;
|
||||||
final List<String> permissions;
|
final List<String> permissions;
|
||||||
final List<ExtensionSetting> settings;
|
final List<ExtensionSetting> settings;
|
||||||
final List<QualityOption> qualityOptions; // Custom quality options for download providers
|
final List<QualityOption> qualityOptions;
|
||||||
final bool hasMetadataProvider;
|
final bool hasMetadataProvider;
|
||||||
final bool hasDownloadProvider;
|
final bool hasDownloadProvider;
|
||||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||||
final SearchBehavior? searchBehavior; // Custom search behavior
|
final SearchBehavior? searchBehavior;
|
||||||
final URLHandler? urlHandler; // Custom URL handling
|
final URLHandler? urlHandler;
|
||||||
final TrackMatching? trackMatching; // Custom track matching
|
final TrackMatching? trackMatching;
|
||||||
final PostProcessing? postProcessing; // Post-processing hooks
|
final PostProcessing? postProcessing;
|
||||||
|
final Map<String, dynamic> capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||||
|
|
||||||
const Extension({
|
const Extension({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -49,6 +49,7 @@ class Extension {
|
|||||||
this.urlHandler,
|
this.urlHandler,
|
||||||
this.trackMatching,
|
this.trackMatching,
|
||||||
this.postProcessing,
|
this.postProcessing,
|
||||||
|
this.capabilities = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Extension.fromJson(Map<String, dynamic> json) {
|
factory Extension.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -85,6 +86,7 @@ class Extension {
|
|||||||
postProcessing: json['post_processing'] != null
|
postProcessing: json['post_processing'] != null
|
||||||
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
? PostProcessing.fromJson(json['post_processing'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
|
capabilities: (json['capabilities'] as Map<String, dynamic>?) ?? const {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ class Extension {
|
|||||||
URLHandler? urlHandler,
|
URLHandler? urlHandler,
|
||||||
TrackMatching? trackMatching,
|
TrackMatching? trackMatching,
|
||||||
PostProcessing? postProcessing,
|
PostProcessing? postProcessing,
|
||||||
|
Map<String, dynamic>? capabilities,
|
||||||
}) {
|
}) {
|
||||||
return Extension(
|
return Extension(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -131,6 +134,7 @@ class Extension {
|
|||||||
urlHandler: urlHandler ?? this.urlHandler,
|
urlHandler: urlHandler ?? this.urlHandler,
|
||||||
trackMatching: trackMatching ?? this.trackMatching,
|
trackMatching: trackMatching ?? this.trackMatching,
|
||||||
postProcessing: postProcessing ?? this.postProcessing,
|
postProcessing: postProcessing ?? this.postProcessing,
|
||||||
|
capabilities: capabilities ?? this.capabilities,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +142,30 @@ class Extension {
|
|||||||
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
||||||
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
||||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||||
|
bool get hasHomeFeed => capabilities['homeFeed'] == true;
|
||||||
|
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchFilter {
|
||||||
|
final String id;
|
||||||
|
final String? label;
|
||||||
|
final String? icon;
|
||||||
|
|
||||||
|
const SearchFilter({
|
||||||
|
required this.id,
|
||||||
|
this.label,
|
||||||
|
this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SearchFilter.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SearchFilter(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
label: json['label'] as String?,
|
||||||
|
icon: json['icon'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Custom search behavior configuration
|
|
||||||
class SearchBehavior {
|
class SearchBehavior {
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final String? placeholder;
|
final String? placeholder;
|
||||||
@@ -149,6 +174,7 @@ class SearchBehavior {
|
|||||||
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||||
final int? thumbnailWidth;
|
final int? thumbnailWidth;
|
||||||
final int? thumbnailHeight;
|
final int? thumbnailHeight;
|
||||||
|
final List<SearchFilter> filters; // Available search filters (e.g., track, album, artist, playlist)
|
||||||
|
|
||||||
const SearchBehavior({
|
const SearchBehavior({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
@@ -158,6 +184,7 @@ class SearchBehavior {
|
|||||||
this.thumbnailRatio,
|
this.thumbnailRatio,
|
||||||
this.thumbnailWidth,
|
this.thumbnailWidth,
|
||||||
this.thumbnailHeight,
|
this.thumbnailHeight,
|
||||||
|
this.filters = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory SearchBehavior.fromJson(Map<String, dynamic> json) {
|
factory SearchBehavior.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -169,18 +196,17 @@ class SearchBehavior {
|
|||||||
thumbnailRatio: json['thumbnailRatio'] as String?,
|
thumbnailRatio: json['thumbnailRatio'] as String?,
|
||||||
thumbnailWidth: json['thumbnailWidth'] as int?,
|
thumbnailWidth: json['thumbnailWidth'] as int?,
|
||||||
thumbnailHeight: json['thumbnailHeight'] as int?,
|
thumbnailHeight: json['thumbnailHeight'] as int?,
|
||||||
|
filters: (json['filters'] as List<dynamic>?)
|
||||||
|
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
|
||||||
|
.toList() ?? [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get thumbnail size based on configuration
|
|
||||||
/// Returns (width, height) tuple
|
|
||||||
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
||||||
// If custom dimensions specified, use them
|
|
||||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise use ratio presets
|
|
||||||
switch (thumbnailRatio) {
|
switch (thumbnailRatio) {
|
||||||
case 'wide': // 16:9 - YouTube style
|
case 'wide': // 16:9 - YouTube style
|
||||||
return (defaultSize * 16 / 9, defaultSize);
|
return (defaultSize * 16 / 9, defaultSize);
|
||||||
@@ -193,11 +219,10 @@ class SearchBehavior {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Custom track matching configuration
|
|
||||||
class TrackMatching {
|
class TrackMatching {
|
||||||
final bool customMatching;
|
final bool customMatching;
|
||||||
final String? strategy; // "isrc", "name", "duration", "custom"
|
final String? strategy;
|
||||||
final int durationTolerance; // in seconds
|
final int durationTolerance;
|
||||||
|
|
||||||
const TrackMatching({
|
const TrackMatching({
|
||||||
required this.customMatching,
|
required this.customMatching,
|
||||||
@@ -214,7 +239,6 @@ class TrackMatching {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Post-processing configuration
|
|
||||||
class PostProcessing {
|
class PostProcessing {
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final List<PostProcessingHook> hooks;
|
final List<PostProcessingHook> hooks;
|
||||||
@@ -264,7 +288,6 @@ class URLHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A post-processing hook
|
|
||||||
class PostProcessingHook {
|
class PostProcessingHook {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -291,12 +314,11 @@ class PostProcessingHook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a quality option for download providers
|
|
||||||
class QualityOption {
|
class QualityOption {
|
||||||
final String id;
|
final String id;
|
||||||
final String label;
|
final String label;
|
||||||
final String? description;
|
final String? description;
|
||||||
final List<QualitySpecificSetting> settings; // Quality-specific settings
|
final List<QualitySpecificSetting> settings;
|
||||||
|
|
||||||
const QualityOption({
|
const QualityOption({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -317,14 +339,13 @@ class QualityOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a setting that's specific to a quality option
|
|
||||||
class QualitySpecificSetting {
|
class QualitySpecificSetting {
|
||||||
final String key;
|
final String key;
|
||||||
final String label;
|
final String label;
|
||||||
final String type; // 'string', 'number', 'boolean', 'select'
|
final String type;
|
||||||
final dynamic defaultValue;
|
final dynamic defaultValue;
|
||||||
final String? description;
|
final String? description;
|
||||||
final List<String>? options; // For select type
|
final List<String>? options;
|
||||||
final bool required;
|
final bool required;
|
||||||
final bool secret;
|
final bool secret;
|
||||||
|
|
||||||
@@ -353,15 +374,15 @@ class QualitySpecificSetting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a setting field for an extension
|
|
||||||
class ExtensionSetting {
|
class ExtensionSetting {
|
||||||
final String key;
|
final String key;
|
||||||
final String label;
|
final String label;
|
||||||
final String type; // 'string', 'number', 'boolean', 'select'
|
final String type;
|
||||||
final dynamic defaultValue;
|
final dynamic defaultValue;
|
||||||
final String? description;
|
final String? description;
|
||||||
final List<String>? options; // For select type
|
final List<String>? options;
|
||||||
final bool required;
|
final bool required;
|
||||||
|
final String? action;
|
||||||
|
|
||||||
const ExtensionSetting({
|
const ExtensionSetting({
|
||||||
required this.key,
|
required this.key,
|
||||||
@@ -371,6 +392,7 @@ class ExtensionSetting {
|
|||||||
this.description,
|
this.description,
|
||||||
this.options,
|
this.options,
|
||||||
this.required = false,
|
this.required = false,
|
||||||
|
this.action,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
|
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -382,11 +404,11 @@ class ExtensionSetting {
|
|||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
||||||
required: json['required'] as bool? ?? false,
|
required: json['required'] as bool? ?? false,
|
||||||
|
action: json['action'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for extension management
|
|
||||||
class ExtensionState {
|
class ExtensionState {
|
||||||
final List<Extension> extensions;
|
final List<Extension> extensions;
|
||||||
final List<String> providerPriority;
|
final List<String> providerPriority;
|
||||||
@@ -424,7 +446,6 @@ class ExtensionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Provider for managing extensions
|
|
||||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||||
@override
|
@override
|
||||||
ExtensionState build() {
|
ExtensionState build() {
|
||||||
@@ -450,7 +471,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load all extensions from directory
|
|
||||||
Future<void> loadExtensions(String dirPath) async {
|
Future<void> loadExtensions(String dirPath) async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
@@ -485,12 +505,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear any error state
|
|
||||||
void clearError() {
|
void clearError() {
|
||||||
state = state.copyWith(error: null);
|
state = state.copyWith(error: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install extension from file (auto-upgrades if already installed with newer version)
|
|
||||||
Future<bool> installExtension(String filePath) async {
|
Future<bool> installExtension(String filePath) async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
@@ -507,8 +525,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a package file is an upgrade for an existing extension
|
|
||||||
/// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed}
|
|
||||||
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) async {
|
||||||
try {
|
try {
|
||||||
return await PlatformBridge.checkExtensionUpgrade(filePath);
|
return await PlatformBridge.checkExtensionUpgrade(filePath);
|
||||||
@@ -518,7 +534,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upgrade an existing extension from a new package file
|
|
||||||
Future<bool> upgradeExtension(String filePath) async {
|
Future<bool> upgradeExtension(String filePath) async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
state = state.copyWith(isLoading: true, error: null);
|
||||||
|
|
||||||
@@ -552,16 +567,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable or disable an extension
|
|
||||||
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
Future<void> setExtensionEnabled(String extensionId, bool enabled) async {
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||||
_log.d('Set extension $extensionId enabled: $enabled');
|
_log.d('Set extension $extensionId enabled: $enabled');
|
||||||
|
|
||||||
// Get extension info before updating state
|
|
||||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||||
|
|
||||||
// Update local state
|
|
||||||
final extensions = state.extensions.map((e) {
|
final extensions = state.extensions.map((e) {
|
||||||
if (e.id == extensionId) {
|
if (e.id == extensionId) {
|
||||||
return e.copyWith(enabled: enabled);
|
return e.copyWith(enabled: enabled);
|
||||||
@@ -571,18 +583,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
|
|
||||||
state = state.copyWith(extensions: extensions);
|
state = state.copyWith(extensions: extensions);
|
||||||
|
|
||||||
// If disabling an extension, reset related settings
|
|
||||||
if (!enabled && ext != null) {
|
if (!enabled && ext != null) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
// If this extension was the search provider, clear it and reset to Deezer
|
|
||||||
if (settings.searchProvider == extensionId) {
|
if (settings.searchProvider == extensionId) {
|
||||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||||
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this extension was the default download service, reset to Tidal
|
|
||||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||||
@@ -604,7 +613,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update settings for an extension
|
|
||||||
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
Future<void> setExtensionSettings(String extensionId, Map<String, dynamic> settings) async {
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.setExtensionSettings(extensionId, settings);
|
await PlatformBridge.setExtensionSettings(extensionId, settings);
|
||||||
@@ -625,7 +633,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set provider priority order
|
|
||||||
Future<void> setProviderPriority(List<String> priority) async {
|
Future<void> setProviderPriority(List<String> priority) async {
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.setProviderPriority(priority);
|
await PlatformBridge.setProviderPriority(priority);
|
||||||
@@ -647,7 +654,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set metadata provider priority order
|
|
||||||
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
Future<void> setMetadataProviderPriority(List<String> priority) async {
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||||
@@ -669,7 +675,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get extension by ID
|
|
||||||
Extension? getExtension(String extensionId) {
|
Extension? getExtension(String extensionId) {
|
||||||
try {
|
try {
|
||||||
return state.extensions.firstWhere((ext) => ext.id == extensionId);
|
return state.extensions.firstWhere((ext) => ext.id == extensionId);
|
||||||
@@ -683,7 +688,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
return state.extensions.where((ext) => ext.enabled).toList();
|
return state.extensions.where((ext) => ext.enabled).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all download providers (built-in + extensions)
|
|
||||||
List<String> getAllDownloadProviders() {
|
List<String> getAllDownloadProviders() {
|
||||||
final providers = ['tidal', 'qobuz', 'amazon'];
|
final providers = ['tidal', 'qobuz', 'amazon'];
|
||||||
for (final ext in state.extensions) {
|
for (final ext in state.extensions) {
|
||||||
@@ -704,7 +708,6 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
return providers;
|
return providers;
|
||||||
}
|
}
|
||||||
/// Get all extensions that provide custom search
|
|
||||||
List<Extension> get searchProviders {
|
List<Extension> get searchProviders {
|
||||||
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
const _recentAccessKey = 'recent_access_history';
|
const _recentAccessKey = 'recent_access_history';
|
||||||
|
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
|
||||||
const _maxRecentItems = 20;
|
const _maxRecentItems = 20;
|
||||||
|
|
||||||
/// Types of items that can be accessed
|
/// Types of items that can be accessed
|
||||||
@@ -75,19 +76,23 @@ class RecentAccessItem {
|
|||||||
/// State for recent access history
|
/// State for recent access history
|
||||||
class RecentAccessState {
|
class RecentAccessState {
|
||||||
final List<RecentAccessItem> items;
|
final List<RecentAccessItem> items;
|
||||||
|
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
|
||||||
final bool isLoaded;
|
final bool isLoaded;
|
||||||
|
|
||||||
const RecentAccessState({
|
const RecentAccessState({
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
|
this.hiddenDownloadIds = const {},
|
||||||
this.isLoaded = false,
|
this.isLoaded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
RecentAccessState copyWith({
|
RecentAccessState copyWith({
|
||||||
List<RecentAccessItem>? items,
|
List<RecentAccessItem>? items,
|
||||||
|
Set<String>? hiddenDownloadIds,
|
||||||
bool? isLoaded,
|
bool? isLoaded,
|
||||||
}) {
|
}) {
|
||||||
return RecentAccessState(
|
return RecentAccessState(
|
||||||
items: items ?? this.items,
|
items: items ?? this.items,
|
||||||
|
hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds,
|
||||||
isLoaded: isLoaded ?? this.isLoaded,
|
isLoaded: isLoaded ?? this.isLoaded,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,6 +100,8 @@ class RecentAccessState {
|
|||||||
|
|
||||||
/// Provider for managing recent access history
|
/// Provider for managing recent access history
|
||||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||||
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
RecentAccessState build() {
|
RecentAccessState build() {
|
||||||
_loadHistory();
|
_loadHistory();
|
||||||
@@ -102,30 +109,42 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
final json = prefs.getString(_recentAccessKey);
|
final json = prefs.getString(_recentAccessKey);
|
||||||
|
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
|
||||||
|
|
||||||
|
List<RecentAccessItem> items = [];
|
||||||
|
Set<String> hiddenIds = {};
|
||||||
|
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
try {
|
try {
|
||||||
final List<dynamic> decoded = jsonDecode(json);
|
final List<dynamic> decoded = jsonDecode(json);
|
||||||
final items = decoded
|
items = decoded
|
||||||
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
state = state.copyWith(items: items, isLoaded: true);
|
} catch (_) {
|
||||||
} catch (e) {
|
// Ignore JSON parse errors, use empty list
|
||||||
// Invalid JSON, start fresh
|
|
||||||
state = state.copyWith(isLoaded: true);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
state = state.copyWith(isLoaded: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hiddenJson != null) {
|
||||||
|
hiddenIds = hiddenJson.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, isLoaded: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveHistory() async {
|
Future<void> _saveHistory() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
|
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
|
||||||
await prefs.setString(_recentAccessKey, json);
|
await prefs.setString(_recentAccessKey, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveHiddenDownloads() async {
|
||||||
|
final prefs = await _prefs;
|
||||||
|
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
|
||||||
|
}
|
||||||
|
|
||||||
/// Record an access to an artist
|
/// Record an access to an artist
|
||||||
void recordArtistAccess({
|
void recordArtistAccess({
|
||||||
required String id,
|
required String id,
|
||||||
@@ -201,29 +220,18 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _recordAccess(RecentAccessItem item) {
|
void _recordAccess(RecentAccessItem item) {
|
||||||
// Debug log
|
|
||||||
// ignore: avoid_print
|
|
||||||
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
|
|
||||||
|
|
||||||
// Remove any existing entry with same unique key
|
|
||||||
final updatedItems = state.items
|
final updatedItems = state.items
|
||||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Add new item at the beginning
|
|
||||||
updatedItems.insert(0, item);
|
updatedItems.insert(0, item);
|
||||||
|
|
||||||
// Limit to max items
|
|
||||||
if (updatedItems.length > _maxRecentItems) {
|
if (updatedItems.length > _maxRecentItems) {
|
||||||
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
|
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(items: updatedItems);
|
state = state.copyWith(items: updatedItems);
|
||||||
_saveHistory();
|
_saveHistory();
|
||||||
|
|
||||||
// Debug log
|
|
||||||
// ignore: avoid_print
|
|
||||||
print('[RecentAccess] Total items now: ${updatedItems.length}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a specific item from history
|
/// Remove a specific item from history
|
||||||
@@ -235,14 +243,31 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
_saveHistory();
|
_saveHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hide a download item from recents (without deleting the actual download)
|
||||||
|
void hideDownloadFromRecents(String downloadId) {
|
||||||
|
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||||
|
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||||
|
_saveHiddenDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a download is hidden from recents
|
||||||
|
bool isDownloadHidden(String downloadId) {
|
||||||
|
return state.hiddenDownloadIds.contains(downloadId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear all history
|
/// Clear all history
|
||||||
void clearHistory() {
|
void clearHistory() {
|
||||||
state = state.copyWith(items: []);
|
state = state.copyWith(items: []);
|
||||||
_saveHistory();
|
_saveHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear hidden downloads (show all again)
|
||||||
|
void clearHiddenDownloads() {
|
||||||
|
state = state.copyWith(hiddenDownloadIds: {});
|
||||||
|
_saveHiddenDownloads();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider instance
|
|
||||||
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
|
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
|
||||||
RecentAccessNotifier.new,
|
RecentAccessNotifier.new,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const _migrationVersionKey = 'settings_migration_version';
|
|||||||
const _currentMigrationVersion = 1;
|
const _currentMigrationVersion = 1;
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AppSettings build() {
|
AppSettings build() {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
@@ -17,50 +19,40 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
|
||||||
// Run migrations if needed
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
|
|
||||||
// Apply Spotify credentials to Go backend on load
|
|
||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
|
|
||||||
// Sync logging state
|
|
||||||
LogBuffer.loggingEnabled = state.enableLogging;
|
LogBuffer.loggingEnabled = state.enableLogging;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run one-time migrations for settings
|
|
||||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||||
|
|
||||||
if (lastMigration < 1) {
|
if (lastMigration < 1) {
|
||||||
// Migration 1: Set metadataSource to 'deezer' for existing users
|
|
||||||
// Only apply if user hasn't enabled custom Spotify credentials
|
|
||||||
// (users with custom credentials likely prefer Spotify)
|
|
||||||
if (!state.useCustomSpotifyCredentials) {
|
if (!state.useCustomSpotifyCredentials) {
|
||||||
state = state.copyWith(metadataSource: 'deezer');
|
state = state.copyWith(metadataSource: 'deezer');
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current migration version
|
|
||||||
if (lastMigration < _currentMigrationVersion) {
|
if (lastMigration < _currentMigrationVersion) {
|
||||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveSettings() async {
|
Future<void> _saveSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await _prefs;
|
||||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply current Spotify credentials to Go backend
|
|
||||||
Future<void> _applySpotifyCredentials() async {
|
Future<void> _applySpotifyCredentials() async {
|
||||||
// Only apply if both fields are set
|
|
||||||
if (state.spotifyClientId.isNotEmpty &&
|
if (state.spotifyClientId.isNotEmpty &&
|
||||||
state.spotifyClientSecret.isNotEmpty) {
|
state.spotifyClientSecret.isNotEmpty) {
|
||||||
await PlatformBridge.setSpotifyCredentials(
|
await PlatformBridge.setSpotifyCredentials(
|
||||||
@@ -68,8 +60,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state.spotifyClientSecret,
|
state.spotifyClientSecret,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Note: If credentials are empty, Spotify API will return error
|
|
||||||
// User should use Deezer as metadata source instead
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDefaultService(String service) {
|
void setDefaultService(String service) {
|
||||||
@@ -102,6 +92,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setLyricsMode(String mode) {
|
||||||
|
if (mode == 'embed' || mode == 'external' || mode == 'both') {
|
||||||
|
state = state.copyWith(lyricsMode: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setMaxQualityCover(bool enabled) {
|
void setMaxQualityCover(bool enabled) {
|
||||||
state = state.copyWith(maxQualityCover: enabled);
|
state = state.copyWith(maxQualityCover: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -113,7 +110,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setConcurrentDownloads(int count) {
|
void setConcurrentDownloads(int count) {
|
||||||
// Clamp between 1 and 3
|
|
||||||
final clamped = count.clamp(1, 3);
|
final clamped = count.clamp(1, 3);
|
||||||
state = state.copyWith(concurrentDownloads: clamped);
|
state = state.copyWith(concurrentDownloads: clamped);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -207,7 +203,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
void setEnableLogging(bool enabled) {
|
void setEnableLogging(bool enabled) {
|
||||||
state = state.copyWith(enableLogging: enabled);
|
state = state.copyWith(enableLogging: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
// Sync logging state to LogBuffer
|
|
||||||
LogBuffer.loggingEnabled = enabled;
|
LogBuffer.loggingEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +230,32 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(locale: locale);
|
state = state.copyWith(locale: locale);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setEnableLossyOption(bool enabled) {
|
||||||
|
state = state.copyWith(enableLossyOption: enabled);
|
||||||
|
// If Lossy is disabled and current quality is LOSSY, reset to LOSSLESS
|
||||||
|
if (!enabled && state.audioQuality == 'LOSSY') {
|
||||||
|
state = state.copyWith(audioQuality: 'LOSSLESS');
|
||||||
|
}
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLossyFormat(String format) {
|
||||||
|
state = state.copyWith(lossyFormat: format);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLossyBitrate(String bitrate) {
|
||||||
|
// Extract format from bitrate (e.g., 'mp3_320' -> 'mp3')
|
||||||
|
final format = bitrate.split('_').first;
|
||||||
|
state = state.copyWith(lossyBitrate: bitrate, lossyFormat: format);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUseAllFilesAccess(bool enabled) {
|
||||||
|
state = state.copyWith(useAllFilesAccess: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
final _log = AppLogger('StoreProvider');
|
final _log = AppLogger('StoreProvider');
|
||||||
|
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
||||||
|
|
||||||
/// Compare two semantic version strings
|
/// Compare two semantic version strings
|
||||||
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||||
int compareVersions(String v1, String v2) {
|
int compareVersions(String v1, String v2) {
|
||||||
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
|
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
|
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
|
|
||||||
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||||
|
|
||||||
@@ -52,7 +53,6 @@ class StoreCategory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents an extension in the store
|
|
||||||
class StoreExtension {
|
class StoreExtension {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -118,7 +118,6 @@ class StoreExtension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for extension store
|
|
||||||
class StoreState {
|
class StoreState {
|
||||||
final List<StoreExtension> extensions;
|
final List<StoreExtension> extensions;
|
||||||
final String? selectedCategory;
|
final String? selectedCategory;
|
||||||
@@ -200,7 +199,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
return const StoreState();
|
return const StoreState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize the store
|
|
||||||
Future<void> initialize(String cacheDir) async {
|
Future<void> initialize(String cacheDir) async {
|
||||||
if (state.isInitialized) return;
|
if (state.isInitialized) return;
|
||||||
|
|
||||||
@@ -234,7 +232,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set category filter
|
|
||||||
void setCategory(String? category) {
|
void setCategory(String? category) {
|
||||||
if (category == null) {
|
if (category == null) {
|
||||||
state = state.copyWith(clearCategory: true);
|
state = state.copyWith(clearCategory: true);
|
||||||
@@ -248,7 +245,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
state = state.copyWith(searchQuery: query);
|
state = state.copyWith(searchQuery: query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear search
|
|
||||||
void clearSearch() {
|
void clearSearch() {
|
||||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||||
}
|
}
|
||||||
@@ -279,7 +275,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update an installed extension
|
|
||||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||||
|
|
||||||
@@ -305,7 +300,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear error
|
|
||||||
void clearError() {
|
void clearError() {
|
||||||
state = state.copyWith(clearError: true);
|
state = state.copyWith(clearError: true);
|
||||||
}
|
}
|
||||||
|
|||||||