Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24ef66be4c | |||
| d07a49f605 | |||
| 4eba28db7a | |||
| b73a3f8912 | |||
| 9f47f2ce85 | |||
| f2aca734a3 | |||
| 09cb637a86 | |||
| 11e7034cec | |||
| f12c18d76b | |||
| 0da39a1b8b | |||
| f29fe5054c | |||
| c8c0164964 | |||
| 52dd657913 | |||
| c30f9fe412 | |||
| bea5dd1d4a | |||
| 8726a0858a | |||
| 74bc747599 | |||
| cbc8fdcb0c | |||
| 3b79b4f1ca | |||
| 5692a76650 | |||
| 7a009ad0af | |||
| e5e75e7092 | |||
| 01b8fd2480 | |||
| ee807a44cc | |||
| c9b905eb18 | |||
| e9c7bf830e | |||
| 8bc97d5bd3 | |||
| f2c241c323 | |||
| 9c512ffe28 | |||
| 53a1da6249 | |||
| d4274e8ca8 | |||
| 49a9f12841 | |||
| d7fa040e3c | |||
| 9baa1e2088 | |||
| 482457205a | |||
| 3b2ec319e2 | |||
| a0f7e75a9a | |||
| c725e53e4c | |||
| 1d7c43a302 | |||
| df7c1c5bb7 | |||
| bb05353b7e | |||
| 7ac92d77e5 | |||
| cf00ecb756 | |||
| 525f2fd0cd | |||
| 3e841cef06 | |||
| a8527df80a | |||
| 51b2ad5c77 | |||
| d641a517b8 | |||
| 608fa2ca74 | |||
| 343b309314 | |||
| 0787b32dd8 | |||
| 6927fdf7a9 | |||
| fe6af34478 | |||
| 85bb67da47 | |||
| 794486a200 | |||
| 8ce5e958ee | |||
| 5c6bf02f1c | |||
| 852335f794 | |||
| b87de1f00a | |||
| 8fcb389bb2 | |||
| 08bca30fcd | |||
| a7c5afdd20 | |||
| 5eac386eba | |||
| d35d60ac7d | |||
| 7c43d4bf70 | |||
| 2043370b6c | |||
| 39ddb7a14f | |||
| bd9b527161 | |||
| 39bcc2c547 | |||
| 973c2e3b41 | |||
| 62805720da | |||
| 0d8234ccd2 | |||
| 0edd616c3d | |||
| 9ca0e8cf5c | |||
| 37b8682faa | |||
| 6563f0f2b3 | |||
| 562fd4d7bb | |||
| 7aa3e77df1 | |||
| 4caa803eb2 | |||
| 6d5c9d0f91 | |||
| 1b2ad4cdd5 | |||
| 33e8ddd758 | |||
| d227d57545 | |||
| db1439e08f | |||
| 47e7850ee0 | |||
| 3ea665dab4 | |||
| bd4acdf222 | |||
| 8ac679003e | |||
| 6a1265eac3 | |||
| 9570547ff9 | |||
| ef62fb218a | |||
| ba5c91090c | |||
| c454bcd5ee | |||
| 4d2ee6fca6 | |||
| 89851bbd62 | |||
| 2c614f9e2f | |||
| f36bee1095 | |||
| e4218a1894 | |||
| db335f5ba6 | |||
| ab9869a849 | |||
| 34791310b7 |
@@ -0,0 +1,123 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug or unexpected behavior
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to report a bug! Please fill out the form below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||||
|
required: true
|
||||||
|
- label: I am using the latest version of SpotiFLAC
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Bug Description
|
||||||
|
description: A clear and concise description of what the bug is
|
||||||
|
placeholder: Describe the bug...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: Steps to reproduce the behavior
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: What did you expect to happen?
|
||||||
|
placeholder: Describe what you expected...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: What actually happened?
|
||||||
|
placeholder: Describe what actually happened...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: App Version
|
||||||
|
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
|
||||||
|
placeholder: "e.g., v2.2.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: Which platform are you using?
|
||||||
|
options:
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Device & OS Version
|
||||||
|
description: What device and OS version are you using?
|
||||||
|
placeholder: "e.g., Samsung Galaxy S24, Android 14"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: download-service
|
||||||
|
attributes:
|
||||||
|
label: Download Service
|
||||||
|
description: Which download service were you using when the bug occurred?
|
||||||
|
options:
|
||||||
|
- Tidal
|
||||||
|
- Qobuz
|
||||||
|
- Amazon Music
|
||||||
|
- Deezer (search only)
|
||||||
|
- Not applicable
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs / Screenshots
|
||||||
|
description: |
|
||||||
|
If applicable, add logs or screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**To get logs:**
|
||||||
|
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||||
|
2. Reproduce the bug
|
||||||
|
3. Go to Settings > Logs
|
||||||
|
4. Tap Share button to export logs
|
||||||
|
placeholder: Paste logs or drag & drop screenshots here...
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Any other context about the problem
|
||||||
|
placeholder: Add any other context...
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: README
|
||||||
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
|
about: Check the README for setup instructions and FAQ
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
name: Download Issue
|
||||||
|
description: Report issues with downloading specific tracks or albums
|
||||||
|
title: "[Download]: "
|
||||||
|
labels: ["download-issue"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Having trouble downloading a specific track or album? Please provide details below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||||
|
required: true
|
||||||
|
- label: I am using the latest version of SpotiFLAC
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: issue-type
|
||||||
|
attributes:
|
||||||
|
label: Issue Type
|
||||||
|
description: What kind of download issue are you experiencing?
|
||||||
|
options:
|
||||||
|
- Track not found on service
|
||||||
|
- Wrong track downloaded
|
||||||
|
- Download fails/errors
|
||||||
|
- Metadata incorrect
|
||||||
|
- Audio quality issue
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: spotify-url
|
||||||
|
attributes:
|
||||||
|
label: Spotify URL
|
||||||
|
description: The Spotify URL of the track/album you're trying to download
|
||||||
|
placeholder: "https://open.spotify.com/track/..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: track-info
|
||||||
|
attributes:
|
||||||
|
label: Track Info
|
||||||
|
description: Artist name and track title
|
||||||
|
placeholder: "Artist - Track Title"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: download-service
|
||||||
|
attributes:
|
||||||
|
label: Download Service
|
||||||
|
description: Which service did you try to download from?
|
||||||
|
options:
|
||||||
|
- Tidal
|
||||||
|
- Qobuz
|
||||||
|
- Amazon Music
|
||||||
|
- All services
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: search-service
|
||||||
|
attributes:
|
||||||
|
label: Search Service
|
||||||
|
description: Which search service are you using?
|
||||||
|
options:
|
||||||
|
- Spotify
|
||||||
|
- Deezer
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Describe the issue in detail
|
||||||
|
placeholder: |
|
||||||
|
What happened? What did you expect?
|
||||||
|
If wrong track was downloaded, what track was downloaded instead?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: App Version
|
||||||
|
description: Which version of SpotiFLAC are you using?
|
||||||
|
placeholder: "e.g., v2.2.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: Screenshots / Logs
|
||||||
|
description: |
|
||||||
|
If applicable, add screenshots or logs.
|
||||||
|
|
||||||
|
**To get logs:**
|
||||||
|
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||||
|
2. Try downloading the track again
|
||||||
|
3. Go to Settings > Logs
|
||||||
|
4. Tap Share button to export logs
|
||||||
|
placeholder: Drag & drop screenshots or paste logs here...
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new feature or improvement
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for suggesting a feature! Please fill out the form below.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Checklist
|
||||||
|
description: Please confirm the following before submitting
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this feature hasn't been requested yet
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem / Motivation
|
||||||
|
description: Is your feature request related to a problem? Please describe.
|
||||||
|
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution
|
||||||
|
description: Describe the solution you'd like
|
||||||
|
placeholder: A clear description of what you want to happen...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: Describe any alternative solutions or features you've considered
|
||||||
|
placeholder: Other approaches you've thought about...
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Category
|
||||||
|
description: What category does this feature fall under?
|
||||||
|
options:
|
||||||
|
- UI/UX Improvement
|
||||||
|
- Download Feature
|
||||||
|
- New Service Integration
|
||||||
|
- Metadata/Tagging
|
||||||
|
- Performance
|
||||||
|
- Settings/Configuration
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, mockups, or screenshots about the feature request
|
||||||
|
placeholder: Add any other context or screenshots...
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
name: Android Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-android:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Java
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: 'temurin'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.21'
|
|
||||||
cache-dependency-path: go_backend/go.sum
|
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Install gomobile
|
|
||||||
run: |
|
|
||||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
|
||||||
gomobile init
|
|
||||||
|
|
||||||
- name: Build Go backend for Android
|
|
||||||
working-directory: go_backend
|
|
||||||
run: |
|
|
||||||
mkdir -p ../android/app/libs
|
|
||||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
|
|
||||||
- name: Setup Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: 'stable'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate app icons
|
|
||||||
run: dart run flutter_launcher_icons
|
|
||||||
|
|
||||||
- name: Build APK (Release)
|
|
||||||
run: flutter build apk --release
|
|
||||||
|
|
||||||
- name: Build App Bundle (Release)
|
|
||||||
run: flutter build appbundle --release
|
|
||||||
|
|
||||||
- name: Upload APK artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SpotiFLAC-Android-APK
|
|
||||||
path: build/app/outputs/flutter-apk/app-release.apk
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload AAB artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SpotiFLAC-Android-AAB
|
|
||||||
path: build/app/outputs/bundle/release/app-release.aab
|
|
||||||
retention-days: 30
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
name: Auto Release on Version Bump
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'pubspec.yaml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version_changed: ${{ steps.check.outputs.changed }}
|
|
||||||
new_version: ${{ steps.check.outputs.version }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Check if version changed
|
|
||||||
id: check
|
|
||||||
run: |
|
|
||||||
# Get current version
|
|
||||||
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
|
||||||
|
|
||||||
# Get previous version
|
|
||||||
git show HEAD~1:pubspec.yaml > /tmp/old_pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > /tmp/old_pubspec.yaml
|
|
||||||
PREVIOUS_VERSION=$(grep '^version:' /tmp/old_pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
|
||||||
|
|
||||||
echo "Current version: $CURRENT_VERSION"
|
|
||||||
echo "Previous version: $PREVIOUS_VERSION"
|
|
||||||
|
|
||||||
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
|
||||||
echo "Version changed!"
|
|
||||||
echo "changed=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "version=v$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "Version unchanged"
|
|
||||||
echo "changed=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
create-tag-and-trigger-release:
|
|
||||||
needs: check-version
|
|
||||||
if: needs.check-version.outputs.version_changed == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Create and push tag
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag ${{ needs.check-version.outputs.new_version }}
|
|
||||||
git push origin ${{ needs.check-version.outputs.new_version }}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Trigger Release workflow
|
|
||||||
run: |
|
|
||||||
gh workflow run release.yml -f version=${{ needs.check-version.outputs.new_version }}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Auto Tag on Version Change
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2 # Need previous commit to compare
|
||||||
|
|
||||||
|
- name: Get current version
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Current version: $VERSION"
|
||||||
|
|
||||||
|
- name: Get previous version
|
||||||
|
id: previous
|
||||||
|
run: |
|
||||||
|
git checkout HEAD~1 -- pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > pubspec.yaml.old
|
||||||
|
if [ -f pubspec.yaml.old ]; then
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml.old | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
else
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
fi
|
||||||
|
git checkout HEAD -- pubspec.yaml
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Previous version: $VERSION"
|
||||||
|
|
||||||
|
- name: Check if version changed
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
CURRENT="${{ steps.current.outputs.version }}"
|
||||||
|
PREVIOUS="${{ steps.previous.outputs.version }}"
|
||||||
|
|
||||||
|
if [ "$CURRENT" != "$PREVIOUS" ]; then
|
||||||
|
echo "Version changed from $PREVIOUS to $CURRENT"
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Version unchanged: $CURRENT"
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check if tag exists
|
||||||
|
id: tag_exists
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.current.outputs.version }}"
|
||||||
|
if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then
|
||||||
|
echo "Tag $TAG already exists"
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Tag $TAG does not exist"
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
if: steps.check.outputs.changed == 'true' && steps.tag_exists.outputs.exists == 'false'
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.current.outputs.version }}"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "$TAG" -m "Release $TAG"
|
||||||
|
git push origin "$TAG"
|
||||||
|
echo "Created and pushed tag: $TAG"
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
name: iOS Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-ios:
|
|
||||||
runs-on: macos-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.21'
|
|
||||||
cache-dependency-path: go_backend/go.sum
|
|
||||||
|
|
||||||
- name: Install gomobile
|
|
||||||
run: |
|
|
||||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
|
||||||
gomobile init
|
|
||||||
|
|
||||||
- name: Build Go backend for iOS (XCFramework)
|
|
||||||
working-directory: go_backend
|
|
||||||
run: |
|
|
||||||
mkdir -p ../ios/Frameworks
|
|
||||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
|
|
||||||
- name: Verify XCFramework created
|
|
||||||
run: |
|
|
||||||
echo "=== Checking XCFramework ==="
|
|
||||||
ls -la ios/Frameworks/
|
|
||||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
|
||||||
echo "=== Debug.xcconfig ==="
|
|
||||||
cat ios/Flutter/Debug.xcconfig
|
|
||||||
echo "=== Release.xcconfig ==="
|
|
||||||
cat ios/Flutter/Release.xcconfig
|
|
||||||
|
|
||||||
- name: Setup Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: 'stable'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate app icons
|
|
||||||
run: dart run flutter_launcher_icons
|
|
||||||
|
|
||||||
- name: Build iOS (no codesign)
|
|
||||||
run: flutter build ios --release --no-codesign
|
|
||||||
|
|
||||||
- name: Create IPA (unsigned)
|
|
||||||
run: |
|
|
||||||
mkdir -p build/ios/ipa
|
|
||||||
cd build/ios/iphoneos
|
|
||||||
mkdir Payload
|
|
||||||
cp -r Runner.app Payload/
|
|
||||||
zip -r ../ipa/SpotiFLAC-unsigned.ipa Payload
|
|
||||||
rm -rf Payload
|
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SpotiFLAC-iOS-unsigned
|
|
||||||
path: build/ios/ipa/SpotiFLAC-unsigned.ipa
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload XCFramework artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Gobackend-XCFramework
|
|
||||||
path: ios/Frameworks/Gobackend.xcframework
|
|
||||||
retention-days: 30
|
|
||||||
@@ -3,47 +3,102 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: 'Version tag (e.g., v1.0.0)'
|
description: "Version tag (e.g., v1.0.0)"
|
||||||
required: true
|
required: true
|
||||||
default: 'v1.0.0'
|
default: "v1.0.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android:
|
# Get version first (quick job)
|
||||||
|
get-version:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: get_version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
else
|
else
|
||||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
fi
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||||
|
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||||
|
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||||
|
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "Detected pre-release version: $VERSION"
|
||||||
|
else
|
||||||
|
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Detected stable version: $VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Android and iOS build in PARALLEL
|
||||||
|
build-android:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: get-version
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Free disk space
|
||||||
|
run: |
|
||||||
|
# Remove large unused tools (~15GB total)
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||||
|
sudo rm -rf /usr/local/share/boost
|
||||||
|
sudo rm -rf /usr/share/swift
|
||||||
|
sudo rm -rf /usr/local/.ghcup
|
||||||
|
# Clean docker images
|
||||||
|
sudo docker image prune --all --force
|
||||||
|
# Show available space
|
||||||
|
df -h
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: "temurin"
|
||||||
java-version: '17'
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: "1.21"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
|
# Cache Gradle for faster builds
|
||||||
|
- name: Cache Gradle
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: gradle-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
- name: Install Android SDK & NDK
|
||||||
uses: android-actions/setup-android@v3
|
run: |
|
||||||
|
# Use pre-installed Android SDK on GitHub runners
|
||||||
|
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||||
|
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||||
|
|
||||||
|
# Accept licenses
|
||||||
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
|
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
||||||
|
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||||
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
||||||
|
|
||||||
|
# Set NDK path
|
||||||
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -61,7 +116,7 @@ jobs:
|
|||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
@@ -70,18 +125,36 @@ jobs:
|
|||||||
- name: Generate app icons
|
- name: Generate app icons
|
||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build APK (Release)
|
- name: Build APK (Release - unsigned)
|
||||||
run: flutter build apk --release --split-per-abi
|
run: |
|
||||||
|
flutter build apk --release --split-per-abi || true
|
||||||
|
# Verify APKs were created
|
||||||
|
ls -la build/app/outputs/flutter-apk/
|
||||||
|
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
||||||
|
echo "ERROR: APK not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Sign APKs
|
||||||
|
uses: r0adkll/sign-android-release@v1
|
||||||
|
id: sign_arm64
|
||||||
|
with:
|
||||||
|
releaseDirectory: build/app/outputs/flutter-apk
|
||||||
|
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
alias: ${{ secrets.KEY_ALIAS }}
|
||||||
|
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
env:
|
||||||
|
BUILD_TOOLS_VERSION: "36.0.0"
|
||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ steps.get_version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cd build/app/outputs/flutter-apk
|
cd build/app/outputs/flutter-apk
|
||||||
# Rename split APKs
|
# Signed files have -signed suffix
|
||||||
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||||
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||||
# Also rename universal if exists
|
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||||
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
|
||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
@@ -92,8 +165,8 @@ jobs:
|
|||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: build-android
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -101,9 +174,17 @@ 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.21"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
|
# Cache CocoaPods
|
||||||
|
- name: Cache CocoaPods
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ios/Pods
|
||||||
|
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||||
|
restore-keys: pods-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||||
@@ -119,20 +200,72 @@ jobs:
|
|||||||
|
|
||||||
- name: Verify XCFramework created
|
- name: Verify XCFramework created
|
||||||
run: |
|
run: |
|
||||||
echo "=== Checking XCFramework ==="
|
|
||||||
ls -la ios/Frameworks/
|
ls -la ios/Frameworks/
|
||||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||||
echo "=== Debug.xcconfig ==="
|
|
||||||
cat ios/Flutter/Debug.xcconfig
|
- name: Add XCFramework to Xcode project
|
||||||
echo "=== Release.xcconfig ==="
|
run: |
|
||||||
cat ios/Flutter/Release.xcconfig
|
# Install xcodeproj gem for modifying Xcode project
|
||||||
|
sudo gem install xcodeproj
|
||||||
|
|
||||||
|
# Create Ruby script to add framework
|
||||||
|
cat > add_framework.rb << 'EOF'
|
||||||
|
require 'xcodeproj'
|
||||||
|
|
||||||
|
project_path = 'ios/Runner.xcodeproj'
|
||||||
|
project = Xcodeproj::Project.open(project_path)
|
||||||
|
|
||||||
|
# Get the main target
|
||||||
|
target = project.targets.find { |t| t.name == 'Runner' }
|
||||||
|
|
||||||
|
# Get or create Frameworks group
|
||||||
|
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||||
|
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||||
|
|
||||||
|
# Add XCFramework reference
|
||||||
|
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||||
|
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||||
|
|
||||||
|
# Add to frameworks build phase
|
||||||
|
frameworks_build_phase = target.frameworks_build_phase
|
||||||
|
frameworks_build_phase.add_file_reference(framework_ref)
|
||||||
|
|
||||||
|
# Add to embed frameworks build phase
|
||||||
|
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||||
|
if embed_phase
|
||||||
|
build_file = embed_phase.add_file_reference(framework_ref)
|
||||||
|
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||||
|
end
|
||||||
|
|
||||||
|
project.save
|
||||||
|
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ruby add_framework.rb
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
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
|
||||||
|
|
||||||
@@ -140,18 +273,44 @@ jobs:
|
|||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build iOS (unsigned)
|
- name: Build iOS (unsigned)
|
||||||
run: flutter build ios --release --no-codesign
|
run: |
|
||||||
|
# Build Flutter iOS without codesigning
|
||||||
|
flutter build ios --release --no-codesign --config-only
|
||||||
|
|
||||||
|
# Use xcodebuild with code signing disabled
|
||||||
|
cd ios
|
||||||
|
xcodebuild -workspace Runner.xcworkspace \
|
||||||
|
-scheme Runner \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphoneos \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-archivePath build/Runner.xcarchive \
|
||||||
|
archive \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
DEVELOPMENT_TEAM=""
|
||||||
|
|
||||||
- name: Create IPA
|
- name: Create IPA
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.build-android.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
mkdir -p build/ios/ipa
|
mkdir -p build/ios/ipa
|
||||||
cd build/ios/iphoneos
|
cd ios/build/Runner.xcarchive/Products/Applications
|
||||||
mkdir Payload
|
mkdir Payload
|
||||||
cp -r Runner.app Payload/
|
cp -r Runner.app Payload/
|
||||||
zip -r ../ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
# Use absolute path to avoid relative path issues
|
||||||
|
zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||||
rm -rf Payload
|
rm -rf Payload
|
||||||
|
|
||||||
|
- name: Verify IPA created
|
||||||
|
run: |
|
||||||
|
ls -la build/ios/ipa/
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
if [ ! -f "build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" ]; then
|
||||||
|
echo "ERROR: IPA not created!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -160,14 +319,39 @@ jobs:
|
|||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-android, build-ios]
|
needs: [get-version, build-android, build-ios]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract changelog for version
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||||
|
|
||||||
|
echo "Looking for version: $VERSION_NUM"
|
||||||
|
|
||||||
|
# Extract changelog section for this version using sed
|
||||||
|
# Find the line with version, then print until next version header or end
|
||||||
|
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||||
|
|
||||||
|
# If no changelog found, use default message
|
||||||
|
if [ -z "$CHANGELOG" ]; then
|
||||||
|
echo "No changelog found for version $VERSION_NUM"
|
||||||
|
CHANGELOG="See CHANGELOG.md for details."
|
||||||
|
else
|
||||||
|
echo "Found changelog content"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save to file for multiline support
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
|
echo "Extracted changelog:"
|
||||||
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -180,38 +364,49 @@ jobs:
|
|||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
|
- name: Prepare release body
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
cat > /tmp/release_body.txt << 'HEADER'
|
||||||
|
### What's New
|
||||||
|
HEADER
|
||||||
|
|
||||||
|
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||||
|
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
#### Android
|
||||||
|
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
||||||
|
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||||
|
|
||||||
|
#### iOS
|
||||||
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
|
  
|
||||||
|
FOOTER
|
||||||
|
|
||||||
|
echo "Release body:"
|
||||||
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.build-android.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.build-android.outputs.version }}
|
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||||
body: |
|
body_path: /tmp/release_body.txt
|
||||||
## SpotiFLAC ${{ needs.build-android.outputs.version }}
|
files: ./release/*
|
||||||
|
|
||||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- **Android (arm64)**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-arm64.apk` (recommended for most devices)
|
|
||||||
- **Android (arm32)**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-arm32.apk` (for older devices)
|
|
||||||
- **iOS**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-ios-unsigned.ipa` (requires sideloading)
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Search Spotify tracks, albums, and playlists
|
|
||||||
- Download in FLAC quality from multiple sources
|
|
||||||
- Automatic fallback to available services
|
|
||||||
- Embedded metadata and cover art
|
|
||||||
- Lyrics support (synced and plain)
|
|
||||||
- Material 3 Expressive UI with dynamic colors
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
|
||||||
|
|
||||||
---
|
|
||||||
*Note: iOS IPA is unsigned and requires sideloading*
|
|
||||||
files: |
|
|
||||||
./release/*
|
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -7,8 +7,46 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
# Kiro specs (optional - remove if you want to track specs)
|
# Kiro specs (development only)
|
||||||
# .kiro/
|
.kiro/
|
||||||
|
|
||||||
# Reference folder (if you don't want to include it)
|
# Reference folder (development only)
|
||||||
# referensi/
|
referensi/
|
||||||
|
|
||||||
|
# Old spotiflac_android folder (moved to root)
|
||||||
|
spotiflac_android/
|
||||||
|
|
||||||
|
# Flutter/Dart
|
||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
build/
|
||||||
|
*.lock
|
||||||
|
!pubspec.lock
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.metadata
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
# Go backend build artifacts
|
||||||
|
go_backend/*.aar
|
||||||
|
go_backend/*.jar
|
||||||
|
go_backend/*.exe
|
||||||
|
go_backend/*.xcframework/
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/.gradle/
|
||||||
|
android/app/libs/gobackend.aar
|
||||||
|
android/local.properties
|
||||||
|
android/*.iml
|
||||||
|
android/key.properties
|
||||||
|
android/*.jks
|
||||||
|
android/*.keystore
|
||||||
|
android/app/*.jks
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
ios/Frameworks/
|
||||||
|
ios/Pods/
|
||||||
|
ios/.symlinks/
|
||||||
|
ios/Flutter/Flutter.framework/
|
||||||
|
ios/Flutter/Flutter.podspec
|
||||||
|
android/app/libs/gobackend-sources.jar
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 zarzet
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
|
||||||

|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="icon.png" width="128" />
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
|
||||||

|

|
||||||
@@ -13,17 +14,43 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
|
||||||
## Screenshot
|
## Screenshots
|
||||||
|
|
||||||
<!--  -->
|
<p align="center">
|
||||||
|
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||||
|
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||||
|
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||||
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Metadata Source
|
||||||
|
|
||||||
|
SpotiFLAC supports two metadata sources for searching tracks:
|
||||||
|
|
||||||
|
| Source | Pros | Cons |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
|
||||||
|
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
|
||||||
|
|
||||||
|
### Using Spotify
|
||||||
|
To use Spotify as your search source without hitting rate limits:
|
||||||
|
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
|
||||||
|
2. Create an app to get your Client ID and Client Secret
|
||||||
|
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
|
||||||
|
4. Enter your Client ID and Secret
|
||||||
|
5. Change **Search Source** to Spotify
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||||
|
|
||||||
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
|
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +8,13 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load keystore properties for local builds
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.zarz.spotiflac"
|
namespace = "com.zarz.spotiflac"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -22,16 +32,25 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 34
|
targetSdk = 36
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
// Only include arm64-v8a for smaller APK (most modern devices)
|
|
||||||
// Remove this line if you need to support older 32-bit devices
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
}
|
}
|
||||||
@@ -39,8 +58,13 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// For local builds: use release signing if key.properties exists
|
||||||
// Enable code shrinking and resource shrinking
|
// For CI builds: APK is signed by GitHub Action after build
|
||||||
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@@ -72,8 +96,11 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
implementation(files("libs/gobackend.aar"))
|
|
||||||
|
// Include all AAR and JAR files from libs folder
|
||||||
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-keep class io.flutter.plugins.** { *; }
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.**
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.**
|
||||||
|
-dontwarn com.google.android.play.core.tasks.**
|
||||||
|
|
||||||
|
# Ignore missing javax.xml.stream (not used on Android)
|
||||||
|
-dontwarn javax.xml.stream.**
|
||||||
|
|
||||||
# Go backend (gobackend.aar)
|
# Go backend (gobackend.aar)
|
||||||
-keep class gobackend.** { *; }
|
-keep class gobackend.** { *; }
|
||||||
-keep class go.** { *; }
|
-keep class go.** { *; }
|
||||||
@@ -14,6 +22,9 @@
|
|||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-keep class com.arthenica.smartexception.** { *; }
|
||||||
|
|
||||||
|
# Apache Tika (if used by FFmpeg)
|
||||||
|
-dontwarn org.apache.tika.**
|
||||||
|
|
||||||
# Keep native methods
|
# Keep native methods
|
||||||
-keepclasseswithmembernames class * {
|
-keepclasseswithmembernames class * {
|
||||||
native <methods>;
|
native <methods>;
|
||||||
|
|||||||
@@ -4,26 +4,30 @@
|
|||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
<!-- For Android 11+ (API 30-32) - full storage access -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="SpotiFLAC"
|
android:label="SpotiFLAC"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:taskAffinity=""
|
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
@@ -76,6 +80,17 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- FileProvider for APK installation -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileProvider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
|
|||||||
@@ -5,83 +5,227 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service to keep downloads running when app is in background.
|
||||||
|
* This prevents Android from killing the download process or throttling network.
|
||||||
|
*
|
||||||
|
* Note: Android 15+ (API 35+) has a 6-hour timeout for dataSync foreground services.
|
||||||
|
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
|
||||||
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "spotiflac_download_channel"
|
private const val CHANNEL_ID = "download_channel"
|
||||||
const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1001
|
||||||
const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD"
|
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
|
||||||
const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD"
|
|
||||||
|
const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD"
|
||||||
|
const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD"
|
||||||
|
const val ACTION_UPDATE_PROGRESS = "com.zarz.spotiflac.action.UPDATE_PROGRESS"
|
||||||
|
|
||||||
|
const val EXTRA_TRACK_NAME = "track_name"
|
||||||
|
const val EXTRA_ARTIST_NAME = "artist_name"
|
||||||
|
const val EXTRA_PROGRESS = "progress"
|
||||||
|
const val EXTRA_TOTAL = "total"
|
||||||
|
const val EXTRA_QUEUE_COUNT = "queue_count"
|
||||||
|
|
||||||
|
private var isRunning = false
|
||||||
|
|
||||||
|
fun isServiceRunning(): Boolean = isRunning
|
||||||
|
|
||||||
|
fun start(context: Context, trackName: String = "", artistName: String = "", queueCount: Int = 0) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_START
|
||||||
|
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||||
|
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||||
|
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_STOP
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProgress(context: Context, trackName: String, artistName: String, progress: Long, total: Long, queueCount: Int) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_PROGRESS
|
||||||
|
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||||
|
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||||
|
putExtra(EXTRA_PROGRESS, progress)
|
||||||
|
putExtra(EXTRA_TOTAL, total)
|
||||||
|
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var currentTrackName = ""
|
||||||
|
private var currentArtistName = ""
|
||||||
|
private var queueCount = 0
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_START -> startForegroundService()
|
ACTION_START -> {
|
||||||
ACTION_STOP -> stopSelf()
|
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: ""
|
||||||
|
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: ""
|
||||||
|
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, 0)
|
||||||
|
startForegroundService()
|
||||||
|
}
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopForegroundService()
|
||||||
|
}
|
||||||
|
ACTION_UPDATE_PROGRESS -> {
|
||||||
|
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: currentTrackName
|
||||||
|
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: currentArtistName
|
||||||
|
val progress = intent.getLongExtra(EXTRA_PROGRESS, 0)
|
||||||
|
val total = intent.getLongExtra(EXTRA_TOTAL, 0)
|
||||||
|
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
updateNotification(progress, total)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the foreground service timeout is reached (Android 15+, API 35+).
|
||||||
|
* dataSync services have a 6-hour limit in a 24-hour period.
|
||||||
|
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||||
|
*/
|
||||||
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
|
// Log the timeout for debugging
|
||||||
|
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
||||||
|
|
||||||
|
// Gracefully stop the service
|
||||||
|
stopForegroundService()
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Download Progress",
|
"Download Service",
|
||||||
NotificationManager.IMPORTANCE_LOW
|
NotificationManager.IMPORTANCE_LOW
|
||||||
).apply {
|
).apply {
|
||||||
description = "Shows download progress for SpotiFLAC"
|
description = "Shows download progress"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
val notification = createNotification("Downloading...", 0)
|
isRunning = true
|
||||||
|
|
||||||
|
// Acquire wake lock to prevent CPU sleep
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = powerManager.newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
WAKELOCK_TAG
|
||||||
|
).apply {
|
||||||
|
acquire(60 * 60 * 1000L) // 1 hour max
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = buildNotification(0, 0)
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProgress(trackName: String, progress: Int) {
|
private fun stopForegroundService() {
|
||||||
val notification = createNotification(trackName, progress)
|
isRunning = false
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(progress: Long, total: Long) {
|
||||||
|
if (!isRunning) return
|
||||||
|
|
||||||
|
val notification = buildNotification(progress, total)
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.notify(NOTIFICATION_ID, notification)
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotification(title: String, progress: Int): Notification {
|
private fun buildNotification(progress: Long, total: Long): Notification {
|
||||||
val intent = Intent(this, MainActivity::class.java)
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
this, 0, intent,
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
val stopIntent = Intent(this, DownloadService::class.java).apply {
|
val title = if (queueCount > 1) {
|
||||||
action = ACTION_STOP
|
"Downloading $queueCount tracks"
|
||||||
|
} else if (currentTrackName.isNotEmpty()) {
|
||||||
|
currentTrackName
|
||||||
|
} else {
|
||||||
|
"Downloading..."
|
||||||
}
|
}
|
||||||
val stopPendingIntent = PendingIntent.getService(
|
|
||||||
this, 0, stopIntent,
|
val text = if (currentArtistName.isNotEmpty() && queueCount <= 1) {
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
currentArtistName
|
||||||
)
|
} else if (total > 0) {
|
||||||
|
val progressPercent = (progress * 100 / total).toInt()
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
val progressMB = progress / (1024.0 * 1024.0)
|
||||||
.setContentTitle("SpotiFLAC")
|
val totalMB = total / (1024.0 * 1024.0)
|
||||||
.setContentText(title)
|
String.format("%.1f / %.1f MB (%d%%)", progressMB, totalMB, progressPercent)
|
||||||
|
} else {
|
||||||
|
"Preparing download..."
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
.setProgress(100, progress, progress == 0)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent)
|
.setOngoing(true)
|
||||||
.build()
|
.setOnlyAlertOnce(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
builder.setProgress(100, (progress * 100 / total).toInt(), false)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
isRunning = false
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.zarz.spotiflac
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
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.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
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
|
||||||
@@ -12,8 +15,15 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
// Update the intent so receive_sharing_intent can access the new data
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
@@ -43,6 +53,15 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"searchSpotifyAll" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
|
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"checkAvailability" -> {
|
"checkAvailability" -> {
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
@@ -71,6 +90,33 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"getAllDownloadProgress" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getAllDownloadProgress()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"initItemProgress" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.initItemProgress(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"finishItemProgress" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.finishItemProgress(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"clearItemProgress" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearItemProgress(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"setDownloadDirectory" -> {
|
"setDownloadDirectory" -> {
|
||||||
val path = call.argument<String>("path") ?: ""
|
val path = call.argument<String>("path") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -114,8 +160,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 filePath = call.argument<String>("file_path") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName)
|
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -127,6 +174,149 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"cleanupConnections" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.cleanupConnections()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"readFileMetadata" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.readFileMetadata(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"startDownloadService" -> {
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||||
|
DownloadService.start(this@MainActivity, trackName, artistName, queueCount)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"stopDownloadService" -> {
|
||||||
|
DownloadService.stop(this@MainActivity)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"updateDownloadServiceProgress" -> {
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val progress = call.argument<Long>("progress") ?: 0L
|
||||||
|
val total = call.argument<Long>("total") ?: 0L
|
||||||
|
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||||
|
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"isDownloadServiceRunning" -> {
|
||||||
|
result.success(DownloadService.isServiceRunning())
|
||||||
|
}
|
||||||
|
"setSpotifyCredentials" -> {
|
||||||
|
val clientId = call.argument<String>("client_id") ?: ""
|
||||||
|
val clientSecret = call.argument<String>("client_secret") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"preWarmTrackCache" -> {
|
||||||
|
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.preWarmTrackCacheJSON(tracksJson)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getTrackCacheSize" -> {
|
||||||
|
val size = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getTrackCacheSize()
|
||||||
|
}
|
||||||
|
result.success(size.toInt())
|
||||||
|
}
|
||||||
|
"clearTrackCache" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearTrackIDCache()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// Deezer API methods
|
||||||
|
"searchDeezerAll" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
|
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getDeezerMetadata" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getDeezerMetadata(resourceType, resourceId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"parseDeezerUrl" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.parseDeezerURLExport(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"searchDeezerByISRC" -> {
|
||||||
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchDeezerByISRC(isrc)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"convertSpotifyToDeezer" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.convertSpotifyToDeezer(resourceType, spotifyId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getSpotifyMetadataWithFallback" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
// Log methods
|
||||||
|
"getLogs" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogs()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getLogsSince" -> {
|
||||||
|
val index = call.argument<Int>("index") ?: 0
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogsSince(index.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"clearLogs" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearLogs()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getLogCount" -> {
|
||||||
|
val count = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getLogCount()
|
||||||
|
}
|
||||||
|
result.success(count.toInt())
|
||||||
|
}
|
||||||
|
"setLoggingEnabled" -> {
|
||||||
|
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setLoggingEnabled(enabled)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -134,5 +324,37 @@ 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: 9.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 651 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<external-files-path name="external_files" path="." />
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
<files-path name="files" path="." />
|
||||||
|
</paths>
|
||||||
@@ -10,10 +10,19 @@ subprojects {
|
|||||||
if (project.hasProperty("android")) {
|
if (project.hasProperty("android")) {
|
||||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable multidex for all subprojects
|
||||||
|
defaultConfig {
|
||||||
|
multiDexEnabled = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add desugaring dependency to all Android subprojects
|
||||||
|
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,208 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||||
|
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('FFmpeg');
|
||||||
|
|
||||||
|
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
||||||
|
class FFmpegServiceIOS {
|
||||||
|
/// Execute FFmpeg command and return result
|
||||||
|
static Future<FFmpegResultIOS> _execute(String command) async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute(command);
|
||||||
|
final returnCode = await session.getReturnCode();
|
||||||
|
final output = await session.getOutput() ?? '';
|
||||||
|
return FFmpegResultIOS(
|
||||||
|
success: ReturnCode.isSuccess(returnCode),
|
||||||
|
returnCode: returnCode?.getValue() ?? -1,
|
||||||
|
output: output,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('FFmpeg execute error: $e');
|
||||||
|
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert M4A (DASH segments) to FLAC
|
||||||
|
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||||
|
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||||
|
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(inputPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FLAC to MP3
|
||||||
|
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
||||||
|
final dir = File(inputPath).parent.path;
|
||||||
|
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
|
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
||||||
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
||||||
|
|
||||||
|
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) return outputPath;
|
||||||
|
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FLAC to M4A
|
||||||
|
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
||||||
|
final dir = File(inputPath).parent.path;
|
||||||
|
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
|
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||||
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
||||||
|
|
||||||
|
String command;
|
||||||
|
if (codec == 'alac') {
|
||||||
|
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
|
} else {
|
||||||
|
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
if (result.success) return outputPath;
|
||||||
|
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embed cover art to FLAC file
|
||||||
|
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after cover embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) await tempFile.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embed metadata and cover art to FLAC file
|
||||||
|
/// Returns the file path on success, null on failure
|
||||||
|
static Future<String?> embedMetadata({
|
||||||
|
required String flacPath,
|
||||||
|
String? coverPath,
|
||||||
|
Map<String, String>? metadata,
|
||||||
|
}) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
|
||||||
|
// Construct command
|
||||||
|
final StringBuffer cmdBuffer = StringBuffer();
|
||||||
|
cmdBuffer.write('-i "$flacPath" ');
|
||||||
|
|
||||||
|
// Add cover input if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-i "$coverPath" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map audio stream
|
||||||
|
cmdBuffer.write('-map 0:a ');
|
||||||
|
|
||||||
|
// Map cover stream if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-map 1:0 ');
|
||||||
|
cmdBuffer.write('-c:v copy ');
|
||||||
|
cmdBuffer.write('-disposition:v attached_pic ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy audio codec (don't re-encode)
|
||||||
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
|
// Add text metadata
|
||||||
|
if (metadata != null) {
|
||||||
|
metadata.forEach((key, value) {
|
||||||
|
// Sanitize value: escape double quotes
|
||||||
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
|
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('"$tempOutput" -y');
|
||||||
|
|
||||||
|
final command = cmdBuffer.toString();
|
||||||
|
_log.d('Executing FFmpeg command: $command');
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after metadata embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file if exists
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if FFmpeg is available
|
||||||
|
static Future<bool> isAvailable() async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute('-version');
|
||||||
|
final returnCode = await session.getReturnCode();
|
||||||
|
return ReturnCode.isSuccess(returnCode);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get FFmpeg version info
|
||||||
|
static Future<String?> getVersion() async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute('-version');
|
||||||
|
return await session.getOutput();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FFmpegResultIOS {
|
||||||
|
final bool success;
|
||||||
|
final int returnCode;
|
||||||
|
final String output;
|
||||||
|
|
||||||
|
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,15 +11,26 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
// 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
|
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 (
|
||||||
|
// Global Amazon downloader instance for connection reuse
|
||||||
|
globalAmazonDownloader *AmazonDownloader
|
||||||
|
amazonDownloaderOnce sync.Once
|
||||||
|
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
|
||||||
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||||
type DoubleDoubleSubmitResponse struct {
|
type DoubleDoubleSubmitResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -36,12 +48,114 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
} `json:"current"`
|
} `json:"current"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
// amazonArtistsMatch checks if the artist names are similar enough
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
return &AmazonDownloader{
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
|
if expectedFirst == foundFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func amazonIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||||
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
|
amazonDownloaderOnce.Do(func() {
|
||||||
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
|
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||||
|
regions: []string{"us", "eu"}, // Same regions as PC
|
||||||
|
apiCallResetTime: time.Now(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalAmazonDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForRateLimit implements rate limiting similar to PC version
|
||||||
|
// Max 9 requests per minute with 7 second delay between requests
|
||||||
|
func (a *AmazonDownloader) waitForRateLimit() {
|
||||||
|
amazonRateLimitMu.Lock()
|
||||||
|
defer amazonRateLimitMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Reset counter every minute
|
||||||
|
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||||
|
a.apiCallCount = 0
|
||||||
|
a.apiCallResetTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've hit the limit (9 requests per minute), wait until next minute
|
||||||
|
if a.apiCallCount >= 9 {
|
||||||
|
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||||
|
if waitTime > 0 {
|
||||||
|
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
a.apiCallCount = 0
|
||||||
|
a.apiCallResetTime = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay between requests (7 seconds like PC version)
|
||||||
|
if !a.lastAPICallTime.IsZero() {
|
||||||
|
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||||
|
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
|
||||||
|
a.lastAPICallTime = time.Now()
|
||||||
|
a.apiCallCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
// GetAvailableAPIs returns list of available DoubleDouble regions
|
||||||
@@ -56,7 +170,6 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
|||||||
return apis
|
return apis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
||||||
// This uses submit → poll → download mechanism
|
// This uses submit → poll → download mechanism
|
||||||
// Internal function - not exported to gomobile
|
// Internal function - not exported to gomobile
|
||||||
@@ -64,18 +177,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
var lastError error
|
var lastError error
|
||||||
|
|
||||||
for _, region := range a.regions {
|
for _, region := range a.regions {
|
||||||
fmt.Printf("[Amazon] Trying region: %s...\n", region)
|
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||||
|
|
||||||
// Build base URL for DoubleDouble service
|
// Build base URL for DoubleDouble service
|
||||||
// Decode base64 service URL (same as PC)
|
// Decode base64 service URL (same as PC)
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||||
|
|
||||||
// Step 1: Submit download request
|
// Step 1: Submit download request with rate limiting
|
||||||
encodedURL := url.QueryEscape(amazonURL)
|
encodedURL := url.QueryEscape(amazonURL)
|
||||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
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)
|
req, err := http.NewRequest("GET", submitURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -85,15 +201,43 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
fmt.Println("[Amazon] Submitting download request...")
|
fmt.Println("[Amazon] Submitting download request...")
|
||||||
resp, err := a.client.Do(req)
|
|
||||||
if err != nil {
|
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
||||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
var resp *http.Response
|
||||||
continue
|
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 resp.StatusCode != 200 {
|
if err != nil || lastError != nil {
|
||||||
resp.Body.Close()
|
if resp != nil {
|
||||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
resp.Body.Close()
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +255,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadID := submitResp.ID
|
downloadID := submitResp.ID
|
||||||
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
|
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
||||||
|
|
||||||
// Step 2: Poll for completion
|
// Step 2: Poll for completion
|
||||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||||
@@ -166,7 +310,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
trackName := status.Current.Name
|
trackName := status.Current.Name
|
||||||
artist := status.Current.Artist
|
artist := status.Current.Artist
|
||||||
|
|
||||||
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||||
return fileURL, trackName, artist, nil
|
return fileURL, trackName, artist, nil
|
||||||
|
|
||||||
} else if status.Status == "error" {
|
} else if status.Status == "error" {
|
||||||
@@ -200,13 +344,13 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
if itemID != "" {
|
||||||
SetDownloading(true)
|
StartItemProgress(itemID)
|
||||||
defer SetDownloading(false)
|
defer CompleteItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,62 +369,130 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Track download progress
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
pw := NewProgressWriter(out)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
_, err = io.Copy(pw, resp.Body)
|
|
||||||
if err != nil {
|
// Use item progress writer with buffered output
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
var written int64
|
||||||
|
if itemID != "" {
|
||||||
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
|
written, err = io.Copy(pw, resp.Body)
|
||||||
|
} else {
|
||||||
|
// Fallback: direct copy without progress tracking
|
||||||
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
// Flush buffer before checking for errors
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size if Content-Length was provided
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
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))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmazonDownloadResult contains download result with quality info
|
||||||
|
type AmazonDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
// downloadFromAmazon downloads a track using the request parameters
|
||||||
// Uses DoubleDouble service (same as PC version)
|
// Uses DoubleDouble service (same as PC version)
|
||||||
func downloadFromAmazon(req DownloadRequest) (string, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from SongLink
|
// Get Amazon URL from SongLink
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
var availability *TrackAvailability
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||||
|
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)
|
||||||
|
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
|
} else if req.SpotifyID != "" {
|
||||||
|
// Use Spotify ID
|
||||||
|
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
|
} else {
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Amazon || availability.AmazonURL == "" {
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
return "", 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
|
// 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 "", 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 DoubleDouble service (same as PC)
|
||||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify artist matches
|
||||||
|
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
|
||||||
|
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -295,69 +507,144 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// 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 "EXISTS:" + outputPath, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
var parallelResult *ParallelDownloadResult
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for parallel operations to complete
|
||||||
|
<-parallelDone
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
// Log track info from DoubleDouble (for debugging)
|
||||||
if trackName != "" && artistName != "" {
|
if trackName != "" && artistName != "" {
|
||||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
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)
|
||||||
|
actualTrackNum := req.TrackNumber
|
||||||
|
actualDiscNum := req.DiscNumber
|
||||||
|
|
||||||
|
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) {
|
||||||
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
|
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||||
|
}
|
||||||
|
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||||
|
actualDiscNum = existingMeta.DiscNumber
|
||||||
|
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
||||||
|
// But preserve track/disc numbers from file if they were better
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: req.TrackName,
|
||||||
Artist: req.ArtistName,
|
Artist: req.ArtistName,
|
||||||
Album: req.AlbumName,
|
Album: req.AlbumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: req.ReleaseDate,
|
Date: req.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: actualTrackNum,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download cover to memory (avoids file permission issues on Android)
|
// Use cover data from parallel fetch
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if req.CoverURL != "" {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
fmt.Println("[Amazon] Downloading cover to memory...")
|
coverData = parallelResult.CoverData
|
||||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
if err == nil {
|
|
||||||
coverData = data
|
|
||||||
fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed lyrics if enabled
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Println("[Amazon] Fetching lyrics...")
|
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
lyricsClient := NewLyricsClient()
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
if lyricsErr != nil {
|
|
||||||
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
|
|
||||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
fmt.Println("[Amazon] No lyrics found for this track")
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||||
lrcContent := convertToLRC(lyrics)
|
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
|
||||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
return outputPath, nil
|
|
||||||
|
// Read actual quality from the downloaded FLAC file
|
||||||
|
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||||
|
quality, err := GetAudioQuality(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
|
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
|
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||||
|
actualTrackNum = finalMeta.TrackNumber
|
||||||
|
actualDiscNum = finalMeta.DiscNumber
|
||||||
|
if finalMeta.Date != "" {
|
||||||
|
// Use date from file if available
|
||||||
|
req.ReleaseDate = finalMeta.Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
|
bitDepth := 0
|
||||||
|
sampleRate := 0
|
||||||
|
if err == nil {
|
||||||
|
bitDepth = quality.BitDepth
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
return AmazonDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: bitDepth,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: actualTrackNum,
|
||||||
|
DiscNumber: actualDiscNum,
|
||||||
|
ISRC: req.ISRC,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,742 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
deezerBaseURL = "https://api.deezer.com/2.0"
|
||||||
|
deezerSearchURL = deezerBaseURL + "/search"
|
||||||
|
deezerTrackURL = deezerBaseURL + "/track/%s"
|
||||||
|
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||||
|
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||||
|
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||||
|
|
||||||
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// Parallel ISRC fetching settings
|
||||||
|
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeezerClient handles Deezer API interactions (no auth required)
|
||||||
|
type DeezerClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
searchCache map[string]*cacheEntry
|
||||||
|
albumCache map[string]*cacheEntry
|
||||||
|
artistCache map[string]*cacheEntry
|
||||||
|
isrcCache map[string]string // trackID -> ISRC cache
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
var (
|
||||||
|
deezerClient *DeezerClient
|
||||||
|
deezerClientOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDeezerClient returns singleton Deezer client
|
||||||
|
func GetDeezerClient() *DeezerClient {
|
||||||
|
deezerClientOnce.Do(func() {
|
||||||
|
deezerClient = &DeezerClient{
|
||||||
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
|
searchCache: make(map[string]*cacheEntry),
|
||||||
|
albumCache: make(map[string]*cacheEntry),
|
||||||
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
isrcCache: make(map[string]string),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return deezerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deezer API response types
|
||||||
|
type deezerTrack struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Duration int `json:"duration"` // in seconds
|
||||||
|
TrackPosition int `json:"track_position"`
|
||||||
|
DiskNumber int `json:"disk_number"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
Album deezerAlbumSimple `json:"album"`
|
||||||
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerArtist struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbFan int `json:"nb_fan"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerAlbumSimple 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"`
|
||||||
|
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... (skip other structs as they are fine/unchanged) ...
|
||||||
|
|
||||||
|
// ... (in convertTrack) ...
|
||||||
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
|
artistName := track.Artist.Name
|
||||||
|
if len(track.Contributors) > 0 {
|
||||||
|
names := make([]string, len(track.Contributors))
|
||||||
|
for i, a := range track.Contributors {
|
||||||
|
names[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
albumImage := track.Album.CoverXL
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverBig
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverMedium
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find release date
|
||||||
|
releaseDate := track.ReleaseDate
|
||||||
|
if releaseDate == "" {
|
||||||
|
releaseDate = track.Album.ReleaseDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: artistName,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: track.Album.Title,
|
||||||
|
AlbumArtist: track.Artist.Name,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: releaseDate, // Added this
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerAlbumFull 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"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
Artist deezerArtist `json:"artist"`
|
||||||
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
|
Tracks struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerArtistFull struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
PictureMedium string `json:"picture_medium"`
|
||||||
|
PictureBig string `json:"picture_big"`
|
||||||
|
PictureXL string `json:"picture_xl"`
|
||||||
|
NbFan int `json:"nb_fan"`
|
||||||
|
NbAlbum int `json:"nb_album"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerPlaylistFull 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"`
|
||||||
|
Creator struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"creator"`
|
||||||
|
Tracks struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
} `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
|
||||||
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
GoLog("[Deezer] SearchAll: returning cached result\n")
|
||||||
|
return entry.data.(*SearchAllResult), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
result := &SearchAllResult{
|
||||||
|
Tracks: make([]TrackMetadata, 0),
|
||||||
|
Artists: make([]SearchArtistResult, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search tracks - NO ISRC fetch for performance
|
||||||
|
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 {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
|
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||||
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackResp.Error != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||||
|
|
||||||
|
for _, track := range trackResp.Data {
|
||||||
|
// Convert directly without fetching ISRC - much faster
|
||||||
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search artists
|
||||||
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
|
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||||
|
|
||||||
|
var artistResp struct {
|
||||||
|
Data []deezerArtist `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
|
if artistResp.Error != nil {
|
||||||
|
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||||
|
for _, artist := range artistResp.Data {
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImage(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrack fetches a single track by Deezer ID
|
||||||
|
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||||
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TrackResponse{
|
||||||
|
Track: c.convertTrack(track),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlbum fetches album with tracks
|
||||||
|
// ISRC is fetched in parallel for better performance
|
||||||
|
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||||
|
|
||||||
|
var album deezerAlbumFull
|
||||||
|
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
albumImage := c.getBestAlbumImage(album)
|
||||||
|
artistName := album.Artist.Name
|
||||||
|
if len(album.Contributors) > 0 {
|
||||||
|
names := make([]string, len(album.Contributors))
|
||||||
|
for i, a := range album.Contributors {
|
||||||
|
names[i] = a.Name
|
||||||
|
}
|
||||||
|
artistName = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := AlbumInfoMetadata{
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
Name: album.Title,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
Artists: artistName,
|
||||||
|
Images: albumImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||||
|
for _, track := range album.Tracks.Data {
|
||||||
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: track.Artist.Name,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: album.Title,
|
||||||
|
AlbumArtist: artistName,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: isrc,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AlbumResponsePayload{
|
||||||
|
AlbumInfo: info,
|
||||||
|
TrackList: tracks,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArtist fetches artist with albums
|
||||||
|
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*ArtistResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Fetch artist info
|
||||||
|
artistURL := fmt.Sprintf(deezerArtistURL, artistID)
|
||||||
|
var artist deezerArtistFull
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artistInfo := ArtistInfoMetadata{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImageFull(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch artist albums
|
||||||
|
albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID))
|
||||||
|
var albumsResp struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
NbTracks int `json:"nb_tracks"`
|
||||||
|
Cover string `json:"cover"`
|
||||||
|
CoverMedium string `json:"cover_medium"`
|
||||||
|
CoverBig string `json:"cover_big"`
|
||||||
|
CoverXL string `json:"cover_xl"`
|
||||||
|
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
albums := make([]ArtistAlbumMetadata, 0)
|
||||||
|
if err := c.getJSON(ctx, albumsURL, &albumsResp); err == nil {
|
||||||
|
for _, album := range albumsResp.Data {
|
||||||
|
albumType := album.RecordType
|
||||||
|
if albumType == "compile" {
|
||||||
|
albumType = "compilation"
|
||||||
|
}
|
||||||
|
|
||||||
|
coverURL := album.CoverXL
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverBig
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.CoverMedium
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
coverURL = album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
albums = append(albums, ArtistAlbumMetadata{
|
||||||
|
ID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
|
Name: album.Title,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TotalTracks: album.NbTracks,
|
||||||
|
Images: coverURL,
|
||||||
|
AlbumType: albumType,
|
||||||
|
Artists: artist.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ArtistResponsePayload{
|
||||||
|
ArtistInfo: artistInfo,
|
||||||
|
Albums: albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
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) {
|
||||||
|
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||||
|
|
||||||
|
var playlist deezerPlaylistFull
|
||||||
|
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistImage := playlist.PictureXL
|
||||||
|
if playlistImage == "" {
|
||||||
|
playlistImage = playlist.PictureBig
|
||||||
|
}
|
||||||
|
if playlistImage == "" {
|
||||||
|
playlistImage = playlist.PictureMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
var info PlaylistInfoMetadata
|
||||||
|
info.Tracks.Total = playlist.NbTracks
|
||||||
|
info.Owner.DisplayName = playlist.Creator.Name
|
||||||
|
info.Owner.Name = playlist.Title
|
||||||
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
||||||
|
for _, track := range playlist.Tracks.Data {
|
||||||
|
albumImage := track.Album.CoverXL
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverBig
|
||||||
|
}
|
||||||
|
if albumImage == "" {
|
||||||
|
albumImage = track.Album.CoverMedium
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
isrc := isrcMap[trackIDStr]
|
||||||
|
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
|
Artists: track.Artist.Name,
|
||||||
|
Name: track.Title,
|
||||||
|
AlbumName: track.Album.Title,
|
||||||
|
AlbumArtist: track.Artist.Name,
|
||||||
|
DurationMS: track.Duration * 1000,
|
||||||
|
Images: albumImage,
|
||||||
|
ReleaseDate: "",
|
||||||
|
TrackNumber: track.TrackPosition,
|
||||||
|
DiscNumber: track.DiskNumber,
|
||||||
|
ExternalURL: track.Link,
|
||||||
|
ISRC: isrc,
|
||||||
|
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PlaylistResponsePayload{
|
||||||
|
PlaylistInfo: info,
|
||||||
|
TrackList: tracks,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchByISRC searches for a track by ISRC using direct endpoint
|
||||||
|
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)
|
||||||
|
|
||||||
|
var track deezerTrack
|
||||||
|
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)
|
||||||
|
var resp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, searchURL, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(resp.Data) == 0 {
|
||||||
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
result := c.convertTrack(resp.Data[0])
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we got a valid response (ID > 0)
|
||||||
|
if track.ID == 0 {
|
||||||
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := c.convertTrack(track)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*deezerTrack, error) {
|
||||||
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
||||||
|
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
|
// First, check cache for existing ISRCs
|
||||||
|
var tracksToFetch []deezerTrack
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
for _, track := range tracks {
|
||||||
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
|
if isrc, ok := c.isrcCache[trackIDStr]; ok {
|
||||||
|
result[trackIDStr] = isrc
|
||||||
|
} else {
|
||||||
|
tracksToFetch = append(tracksToFetch, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
if len(tracksToFetch) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use semaphore to limit concurrent requests
|
||||||
|
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, track := range tracksToFetch {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(t deezerTrack) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Acquire semaphore
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDStr := fmt.Sprintf("%d", t.ID)
|
||||||
|
fullTrack, err := c.fetchFullTrack(ctx, trackIDStr)
|
||||||
|
if err != nil || fullTrack == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in result and cache
|
||||||
|
resultMu.Lock()
|
||||||
|
result[trackIDStr] = fullTrack.ISRC
|
||||||
|
resultMu.Unlock()
|
||||||
|
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
}(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrackISRC fetches ISRC for a single track (with caching)
|
||||||
|
// Use this when you need ISRC for download
|
||||||
|
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return isrc, nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Fetch from API
|
||||||
|
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.isrcCache[trackID] = fullTrack.ISRC
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return fullTrack.ISRC, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||||
|
if artist.PictureXL != "" {
|
||||||
|
return artist.PictureXL
|
||||||
|
}
|
||||||
|
if artist.PictureBig != "" {
|
||||||
|
return artist.PictureBig
|
||||||
|
}
|
||||||
|
if artist.PictureMedium != "" {
|
||||||
|
return artist.PictureMedium
|
||||||
|
}
|
||||||
|
return artist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestArtistImageFull(artist deezerArtistFull) string {
|
||||||
|
if artist.PictureXL != "" {
|
||||||
|
return artist.PictureXL
|
||||||
|
}
|
||||||
|
if artist.PictureBig != "" {
|
||||||
|
return artist.PictureBig
|
||||||
|
}
|
||||||
|
if artist.PictureMedium != "" {
|
||||||
|
return artist.PictureMedium
|
||||||
|
}
|
||||||
|
return artist.Picture
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||||
|
if album.CoverXL != "" {
|
||||||
|
return album.CoverXL
|
||||||
|
}
|
||||||
|
if album.CoverBig != "" {
|
||||||
|
return album.CoverBig
|
||||||
|
}
|
||||||
|
if album.CoverMedium != "" {
|
||||||
|
return album.CoverMedium
|
||||||
|
}
|
||||||
|
return album.Cover
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(body, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDeezerURL is internal function, returns type and ID
|
||||||
|
func parseDeezerURL(input string) (string, string, error) {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", "", fmt.Errorf("empty URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Host != "www.deezer.com" && parsed.Host != "deezer.com" && parsed.Host != "deezer.page.link" {
|
||||||
|
return "", "", fmt.Errorf("not a Deezer URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
|
||||||
|
// Skip language prefix if present (e.g., /en/, /fr/)
|
||||||
|
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||||
|
parts = parts[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", "", fmt.Errorf("invalid Deezer URL format")
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceType := parts[0]
|
||||||
|
resourceID := parts[1]
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track", "album", "artist", "playlist":
|
||||||
|
return resourceType, resourceID, nil
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,144 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
|
||||||
|
type ISRCIndex struct {
|
||||||
|
index map[string]string // ISRC (uppercase) -> file path
|
||||||
|
outputDir string
|
||||||
|
buildTime time.Time
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global ISRC index cache (per output directory)
|
||||||
|
var (
|
||||||
|
isrcIndexCache = make(map[string]*ISRCIndex)
|
||||||
|
isrcIndexCacheMu sync.RWMutex
|
||||||
|
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetISRCIndex returns or builds an ISRC index for the given directory
|
||||||
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Return cached index if still valid
|
||||||
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new index
|
||||||
|
return buildISRCIndex(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||||
|
// Same implementation as PC version for consistency
|
||||||
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
|
idx := &ISRCIndex{
|
||||||
|
index: make(map[string]string),
|
||||||
|
outputDir: outputDir,
|
||||||
|
buildTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputDir == "" {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
fileCount := 0
|
||||||
|
|
||||||
|
// Walk directory - only check .flac files
|
||||||
|
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if ext != ".flac" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read ISRC from file
|
||||||
|
metadata, err := ReadMetadata(path)
|
||||||
|
if err != nil || metadata.ISRC == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in index (uppercase for case-insensitive matching)
|
||||||
|
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||||
|
fileCount++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
|
// Cache the index
|
||||||
|
isrcIndexCacheMu.Lock()
|
||||||
|
isrcIndexCache[outputDir] = idx
|
||||||
|
isrcIndexCacheMu.Unlock()
|
||||||
|
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup checks if an ISRC exists in the index (internal, returns bool)
|
||||||
|
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||||
|
if isrc == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.mu.RLock()
|
||||||
|
defer idx.mu.RUnlock()
|
||||||
|
|
||||||
|
path, exists := idx.index[strings.ToUpper(isrc)]
|
||||||
|
return path, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||||
|
// Returns filepath if found, empty string if not found
|
||||||
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
|
path, _ := idx.lookup(isrc)
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a new ISRC to the index (call after successful download)
|
||||||
|
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||||
|
if isrc == "" || filePath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.mu.Lock()
|
||||||
|
defer idx.mu.Unlock()
|
||||||
|
|
||||||
|
idx.index[strings.ToUpper(isrc)] = filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateCache clears the ISRC index cache for a directory
|
||||||
|
func InvalidateISRCCache(outputDir string) {
|
||||||
|
isrcIndexCacheMu.Lock()
|
||||||
|
delete(isrcIndexCache, outputDir)
|
||||||
|
isrcIndexCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
||||||
|
// Uses ISRC index for fast lookup
|
||||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||||
if isrc == "" || outputDir == "" {
|
if isrc == "" || outputDir == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk through directory looking for FLAC files
|
// Use index for fast lookup
|
||||||
var foundFile string
|
idx := GetISRCIndex(outputDir)
|
||||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
return idx.lookup(isrc)
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only check FLAC files
|
|
||||||
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".flac") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read metadata from file
|
|
||||||
metadata, err := ReadMetadata(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ISRC matches
|
|
||||||
if metadata.ISRC == isrc {
|
|
||||||
foundFile = path
|
|
||||||
return filepath.SkipAll // Stop walking
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if foundFile != "" {
|
|
||||||
return foundFile, true
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||||
@@ -61,3 +156,98 @@ func CheckFileExists(filePath string) bool {
|
|||||||
}
|
}
|
||||||
return !info.IsDir() && info.Size() > 0
|
return !info.IsDir() && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileExistenceResult represents the result of checking if a file exists
|
||||||
|
type FileExistenceResult struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
TrackName string `json:"track_name,omitempty"`
|
||||||
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Parse input JSON
|
||||||
|
var tracks []struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]FileExistenceResult, len(tracks))
|
||||||
|
|
||||||
|
// Build ISRC index from output directory (scan once)
|
||||||
|
isrcIdx := GetISRCIndex(outputDir)
|
||||||
|
|
||||||
|
// Check each track against the index (parallel)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, track := range tracks {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(resultIdx int, t struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
}) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
result := FileExistenceResult{
|
||||||
|
ISRC: t.ISRC,
|
||||||
|
TrackName: t.TrackName,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
Exists: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.ISRC != "" {
|
||||||
|
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
|
||||||
|
result.Exists = true
|
||||||
|
result.FilePath = filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[resultIdx] = result
|
||||||
|
}(i, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Return results as JSON
|
||||||
|
resultJSON, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(resultJSON), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreBuildISRCIndex pre-builds the ISRC index for a directory
|
||||||
|
// Call this when app starts or when entering album/playlist screen
|
||||||
|
func PreBuildISRCIndex(outputDir string) error {
|
||||||
|
if outputDir == "" {
|
||||||
|
return fmt.Errorf("output directory is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildISRCIndex(outputDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
||||||
|
// This avoids rebuilding the entire index
|
||||||
|
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||||
|
if outputDir == "" || isrc == "" || filePath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
idx.Add(isrc, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,37 +17,43 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]string{
|
result := map[string]string{
|
||||||
"type": parsed.Type,
|
"type": parsed.Type,
|
||||||
"id": parsed.ID,
|
"id": parsed.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||||
|
// Pass empty strings to use default credentials
|
||||||
|
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||||
|
SetSpotifyCredentials(clientID, clientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||||
// Returns JSON with track/album/playlist data
|
// 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()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(data)
|
jsonBytes, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,18 +62,38 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
|||||||
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()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
results, err := client.SearchTracks(ctx, query, limit)
|
results, err := client.SearchTracks(ctx, query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(results)
|
jsonBytes, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := NewSpotifyMetadataClient()
|
||||||
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,12 +105,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(availability)
|
jsonBytes, err := json.Marshal(availability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,12 +126,15 @@ 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
|
||||||
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
|
||||||
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResponse represents the result of a download
|
// DownloadResponse represents the result of a download
|
||||||
@@ -112,7 +143,34 @@ type DownloadResponse struct {
|
|||||||
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"
|
||||||
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"`
|
||||||
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
|
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
ISRC string `json:"isrc,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadResult is a generic result type for all downloaders
|
||||||
|
// DownloadResult is a generic result type for all downloaders
|
||||||
|
type DownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadTrack downloads a track from the specified service
|
// DownloadTrack downloads a track from the specified service
|
||||||
@@ -123,43 +181,132 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
var filePath string
|
// Trim whitespace from string fields to prevent filename/path issues
|
||||||
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch req.Service {
|
switch req.Service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
filePath, err = downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
|
if tidalErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: tidalResult.FilePath,
|
||||||
|
BitDepth: tidalResult.BitDepth,
|
||||||
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
Title: tidalResult.Title,
|
||||||
|
Artist: tidalResult.Artist,
|
||||||
|
Album: tidalResult.Album,
|
||||||
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
|
ISRC: tidalResult.ISRC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
filePath, err = downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
|
if qobuzErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: qobuzResult.FilePath,
|
||||||
|
BitDepth: qobuzResult.BitDepth,
|
||||||
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
Title: qobuzResult.Title,
|
||||||
|
Artist: qobuzResult.Artist,
|
||||||
|
Album: qobuzResult.Album,
|
||||||
|
ReleaseDate: qobuzResult.ReleaseDate,
|
||||||
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
|
ISRC: qobuzResult.ISRC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
filePath, err = downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
|
if amazonErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: amazonResult.FilePath,
|
||||||
|
BitDepth: amazonResult.BitDepth,
|
||||||
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
Artist: amazonResult.Artist,
|
||||||
|
Album: amazonResult.Album,
|
||||||
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
ISRC: amazonResult.ISRC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = amazonErr
|
||||||
default:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorResponse(err.Error())
|
return errorResponse(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
Success: true,
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
Message: "Download complete",
|
if qErr == nil {
|
||||||
FilePath: filePath,
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resp := DownloadResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Download complete",
|
||||||
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
@@ -171,14 +318,23 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
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.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
// 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 == "" {
|
||||||
preferredService = "tidal"
|
preferredService = "tidal"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||||
|
|
||||||
// Create ordered list: preferred first, then others
|
// Create ordered list: preferred first, then others
|
||||||
services := []string{preferredService}
|
services := []string{preferredService}
|
||||||
for _, s := range allServices {
|
for _, s := range allServices {
|
||||||
@@ -186,49 +342,140 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
services = append(services, s)
|
services = append(services, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[DownloadWithFallback] Service order: %v\n", services)
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
|
GoLog("[DownloadWithFallback] Trying service: %s\n", service)
|
||||||
req.Service = service
|
req.Service = service
|
||||||
|
|
||||||
var filePath string
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch service {
|
switch service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
filePath, err = downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
|
if tidalErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: tidalResult.FilePath,
|
||||||
|
BitDepth: tidalResult.BitDepth,
|
||||||
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
Title: tidalResult.Title,
|
||||||
|
Artist: tidalResult.Artist,
|
||||||
|
Album: tidalResult.Album,
|
||||||
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
|
ISRC: tidalResult.ISRC,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||||
|
}
|
||||||
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
filePath, err = downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
|
if qobuzErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: qobuzResult.FilePath,
|
||||||
|
BitDepth: qobuzResult.BitDepth,
|
||||||
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
Title: qobuzResult.Title,
|
||||||
|
Artist: qobuzResult.Artist,
|
||||||
|
Album: qobuzResult.Album,
|
||||||
|
ReleaseDate: qobuzResult.ReleaseDate,
|
||||||
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
|
ISRC: qobuzResult.ISRC,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
|
}
|
||||||
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
filePath, err = downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
|
if amazonErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: amazonResult.FilePath,
|
||||||
|
BitDepth: amazonResult.BitDepth,
|
||||||
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
Artist: amazonResult.Artist,
|
||||||
|
Album: amazonResult.Album,
|
||||||
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
ISRC: amazonResult.ISRC,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
|
}
|
||||||
|
err = amazonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Downloaded from " + service,
|
Message: "Downloaded from " + service,
|
||||||
FilePath: filePath,
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
|
ISRC: result.ISRC,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +486,78 @@ func GetDownloadProgress() string {
|
|||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
|
||||||
|
func GetAllDownloadProgress() string {
|
||||||
|
return GetMultiProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitItemProgress initializes progress tracking for a download item
|
||||||
|
func InitItemProgress(itemID string) {
|
||||||
|
StartItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishItemProgress marks a download item as complete and removes tracking
|
||||||
|
func FinishItemProgress(itemID string) {
|
||||||
|
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) {
|
||||||
|
RemoveItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupConnections closes idle HTTP connections
|
||||||
|
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||||
|
func CleanupConnections() {
|
||||||
|
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) {
|
||||||
|
metadata, err := ReadMetadata(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also get audio quality info
|
||||||
|
quality, qualityErr := GetAudioQuality(filePath)
|
||||||
|
|
||||||
|
// Get duration from FLAC stream info
|
||||||
|
duration := 0
|
||||||
|
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
|
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"title": metadata.Title,
|
||||||
|
"artist": metadata.Artist,
|
||||||
|
"album": metadata.Album,
|
||||||
|
"album_artist": metadata.AlbumArtist,
|
||||||
|
"date": metadata.Date,
|
||||||
|
"track_number": metadata.TrackNumber,
|
||||||
|
"disc_number": metadata.DiscNumber,
|
||||||
|
"isrc": metadata.ISRC,
|
||||||
|
"lyrics": metadata.Lyrics,
|
||||||
|
"duration": duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add quality info if available
|
||||||
|
if qualityErr == nil {
|
||||||
|
result["bit_depth"] = quality.BitDepth
|
||||||
|
result["sample_rate"] = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetDownloadDirectory sets the default download directory
|
// SetDownloadDirectory sets the default download directory
|
||||||
func SetDownloadDirectory(path string) error {
|
func SetDownloadDirectory(path string) error {
|
||||||
return setDownloadDir(path)
|
return setDownloadDir(path)
|
||||||
@@ -247,27 +566,47 @@ func SetDownloadDirectory(path string) error {
|
|||||||
// CheckDuplicate checks if a file with the given ISRC exists
|
// 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)
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"exists": exists,
|
"exists": exists,
|
||||||
"filepath": existingFile,
|
"filepath": existingFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
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 {
|
||||||
|
return PreBuildISRCIndex(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
||||||
|
// Call this when files are deleted or moved
|
||||||
|
func InvalidateDuplicateIndex(outputDir string) {
|
||||||
|
InvalidateISRCCache(outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
// BuildFilename builds a filename from template and metadata
|
// 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 {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(template, metadata)
|
filename := buildFilenameFromTemplate(template, metadata)
|
||||||
return filename, nil
|
return filename, nil
|
||||||
}
|
}
|
||||||
@@ -301,15 +640,26 @@ 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
|
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string) (string, error) {
|
// First tries to extract from file, then falls back to fetching from internet
|
||||||
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
||||||
|
// Try to extract from file first (much faster)
|
||||||
|
if filePath != "" {
|
||||||
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
|
if err == nil && lyrics != "" {
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to fetching from internet
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyrics)
|
// Convert to LRC format with metadata headers (like PC version)
|
||||||
|
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,10 +679,339 @@ 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) {
|
||||||
|
var tracks []struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||||
|
return errorResponse("Invalid JSON: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to PreWarmCacheRequest
|
||||||
|
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||||
|
for i, t := range tracks {
|
||||||
|
requests[i] = PreWarmCacheRequest{
|
||||||
|
ISRC: t.ISRC,
|
||||||
|
TrackName: t.TrackName,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
SpotifyID: t.SpotifyID,
|
||||||
|
Service: t.Service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run in background
|
||||||
|
go PreWarmTrackCache(requests)
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)),
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrackCacheSize returns the current track ID cache size
|
||||||
|
func GetTrackCacheSize() int {
|
||||||
|
return GetCacheSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTrackIDCache clears the track ID cache
|
||||||
|
func ClearTrackIDCache() {
|
||||||
|
ClearTrackCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DEEZER API ====================
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := GetDeezerClient()
|
||||||
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerMetadata fetches metadata from Deezer URL or ID
|
||||||
|
// resourceType: track, album, artist, playlist
|
||||||
|
// resourceID: Deezer ID
|
||||||
|
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := GetDeezerClient()
|
||||||
|
var data interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch resourceType {
|
||||||
|
case "track":
|
||||||
|
data, err = client.GetTrack(ctx, resourceID)
|
||||||
|
case "album":
|
||||||
|
data, err = client.GetAlbum(ctx, resourceID)
|
||||||
|
case "artist":
|
||||||
|
data, err = client.GetArtist(ctx, resourceID)
|
||||||
|
case "playlist":
|
||||||
|
data, err = client.GetPlaylist(ctx, resourceID)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDeezerURLExport parses a Deezer URL and returns type and ID
|
||||||
|
func ParseDeezerURLExport(url string) (string, error) {
|
||||||
|
resourceType, resourceID, err := parseDeezerURL(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]string{
|
||||||
|
"type": resourceType,
|
||||||
|
"id": resourceID,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDeezerByISRC searches for a track by ISRC on Deezer
|
||||||
|
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := GetDeezerClient()
|
||||||
|
track, err := client.SearchByISRC(ctx, isrc)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(track)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
deezerClient := GetDeezerClient()
|
||||||
|
|
||||||
|
// For tracks, we can use SongLink to get Deezer ID
|
||||||
|
if resourceType == "track" {
|
||||||
|
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch metadata from Deezer
|
||||||
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(trackResp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For albums, SongLink also provides mapping
|
||||||
|
if resourceType == "album" {
|
||||||
|
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch album metadata from Deezer
|
||||||
|
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(albumResp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For artists/playlists, SongLink doesn't provide direct mapping
|
||||||
|
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpotifyMetadataWithDeezerFallback tries Spotify first, falls back to Deezer on rate limit
|
||||||
|
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Try Spotify first
|
||||||
|
client := NewSpotifyMetadataClient()
|
||||||
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
|
if err == nil {
|
||||||
|
jsonBytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a rate limit error
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||||
|
// Not a rate limit error, return original error
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limited - try Deezer fallback for tracks and albums
|
||||||
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||||
|
|
||||||
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
|
// Convert to Deezer
|
||||||
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artist and playlist not supported for fallback
|
||||||
|
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. 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) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(availability)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAvailabilityByPlatformID checks track availability using any platform as source
|
||||||
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube"
|
||||||
|
// entityType: "song" or "album"
|
||||||
|
// entityID: the ID on that platform
|
||||||
|
func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(availability)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpotifyIDFromDeezerTrack converts a Deezer track ID to Spotify track ID
|
||||||
|
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
return client.GetSpotifyIDFromDeezer(deezerTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTidalURLFromDeezerTrack converts a Deezer track ID to Tidal URL
|
||||||
|
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
return client.GetTidalURLFromDeezer(deezerTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmazonURLFromDeezerTrack converts a Deezer track ID to Amazon Music URL
|
||||||
|
func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
return client.GetAmazonURLFromDeezer(deezerTrackID)
|
||||||
|
}
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
|
// Determine error type based on message
|
||||||
|
errorType := "unknown"
|
||||||
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
|
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||||
|
strings.Contains(lowerMsg, "try using vpn") ||
|
||||||
|
strings.Contains(lowerMsg, "change dns") {
|
||||||
|
errorType = "isp_blocked"
|
||||||
|
} else if strings.Contains(lowerMsg, "not found") ||
|
||||||
|
strings.Contains(lowerMsg, "not available") ||
|
||||||
|
strings.Contains(lowerMsg, "no results") ||
|
||||||
|
strings.Contains(lowerMsg, "track not found") ||
|
||||||
|
strings.Contains(lowerMsg, "all services failed") {
|
||||||
|
errorType = "not_found"
|
||||||
|
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||||
|
strings.Contains(lowerMsg, "429") ||
|
||||||
|
strings.Contains(lowerMsg, "too many requests") {
|
||||||
|
errorType = "rate_limit"
|
||||||
|
} else if strings.Contains(lowerMsg, "network") ||
|
||||||
|
strings.Contains(lowerMsg, "connection") ||
|
||||||
|
strings.Contains(lowerMsg, "timeout") ||
|
||||||
|
strings.Contains(lowerMsg, "dial") {
|
||||||
|
errorType = "network"
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: msg,
|
Error: msg,
|
||||||
|
ErrorType: errorType,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ 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 {
|
||||||
return s
|
// Trim leading/trailing whitespace to prevent filename issues
|
||||||
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,35 +1,75 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTTP utility functions for consistent request handling across all downloaders
|
// HTTP utility functions for consistent request handling across all downloaders
|
||||||
|
|
||||||
// User-Agent pool for Android Chrome browsers
|
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||||
var userAgentTemplates = []string{
|
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
||||||
"Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
func getRandomUserAgent() string {
|
||||||
"Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
||||||
"Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
// Some APIs may block mobile User-Agents, so we use desktop format
|
||||||
"Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
||||||
|
|
||||||
|
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(
|
||||||
|
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||||
|
winMajor,
|
||||||
|
chromeVersion,
|
||||||
|
chromeBuild,
|
||||||
|
chromePatch,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRandomUserAgent generates a random browser-like User-Agent string (Android Chrome format)
|
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||||
func getRandomUserAgent() string {
|
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||||
template := userAgentTemplates[rand.Intn(len(userAgentTemplates))]
|
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
|
||||||
|
|
||||||
androidVersion := rand.Intn(5) + 10 // Android 10-14
|
return fmt.Sprintf(
|
||||||
deviceModel := rand.Intn(900) + 100 // Random model number
|
"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",
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
macMajor,
|
||||||
chromeBuild := rand.Intn(5000) + 5000
|
macMinor,
|
||||||
chromePatch := rand.Intn(200) + 100
|
webkitMajor,
|
||||||
|
webkitMinor,
|
||||||
|
chromeMajor,
|
||||||
|
chromeBuild,
|
||||||
|
chromePatch,
|
||||||
|
safariMajor,
|
||||||
|
safariMinor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(template, androidVersion, deviceModel, chromeVersion, chromeBuild, chromePatch)
|
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||||
|
func getRandomDesktopUserAgent() string {
|
||||||
|
if rand.Intn(2) == 0 {
|
||||||
|
return getRandomUserAgent() // Windows
|
||||||
|
}
|
||||||
|
return getRandomMacUserAgent() // Mac
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default timeout values
|
// Default timeout values
|
||||||
@@ -41,17 +81,73 @@ const (
|
|||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
|
// Optimized for large file downloads (FLAC ~30-50MB)
|
||||||
|
var sharedTransport = &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
MaxConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||||
|
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||||
|
DisableCompression: true, // FLAC is already compressed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared HTTP client for general requests (reuses connections)
|
||||||
|
var sharedClient = &http.Client{
|
||||||
|
Transport: sharedTransport,
|
||||||
|
Timeout: DefaultTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared HTTP client for downloads (longer timeout, reuses connections)
|
||||||
|
var downloadClient = &http.Client{
|
||||||
|
Transport: sharedTransport,
|
||||||
|
Timeout: DownloadTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
// 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{
|
||||||
Timeout: timeout,
|
Transport: sharedTransport,
|
||||||
|
Timeout: timeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSharedClient returns the shared HTTP client for general requests
|
||||||
|
func GetSharedClient() *http.Client {
|
||||||
|
return sharedClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDownloadClient returns the shared HTTP client for downloads
|
||||||
|
func GetDownloadClient() *http.Client {
|
||||||
|
return downloadClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseIdleConnections closes idle connections in the shared transport
|
||||||
|
// Call this periodically during large batch downloads to prevent connection buildup
|
||||||
|
func CloseIdleConnections() {
|
||||||
|
sharedTransport.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
|
||||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||||
|
// 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())
|
||||||
return client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// Check for ISP blocking
|
||||||
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryConfig holds configuration for retry logic
|
// RetryConfig holds configuration for retry logic
|
||||||
@@ -74,9 +170,11 @@ func DefaultRetryConfig() RetryConfig {
|
|||||||
|
|
||||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
// Handles 429 (Too Many Requests) responses with Retry-After header
|
||||||
|
// Also detects and logs ISP blocking
|
||||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
delay := config.InitialDelay
|
delay := config.InitialDelay
|
||||||
|
requestURL := req.URL.String()
|
||||||
|
|
||||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||||
// Clone request for retry (body needs to be re-readable)
|
// Clone request for retry (body needs to be re-readable)
|
||||||
@@ -86,7 +184,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
resp, err := client.Do(reqCopy)
|
resp, err := client.Do(reqCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
|
// Check for ISP blocking on network errors
|
||||||
|
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||||
|
// Don't retry if ISP blocking is detected - it won't help
|
||||||
|
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||||
|
}
|
||||||
|
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||||
|
attempt+1, config.MaxRetries+1, err, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
@@ -107,17 +214,43 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
}
|
}
|
||||||
lastErr = fmt.Errorf("rate limited (429)")
|
lastErr = fmt.Errorf("rate limited (429)")
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for ISP blocking via HTTP status codes
|
||||||
|
// Some ISPs return 403 or 451 when blocking content
|
||||||
|
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
|
||||||
|
// Check if response looks like ISP blocking page
|
||||||
|
ispBlockingIndicators := []string{
|
||||||
|
"blocked", "forbidden", "access denied", "not available in your",
|
||||||
|
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, indicator := range ispBlockingIndicators {
|
||||||
|
if strings.Contains(bodyStr, indicator) {
|
||||||
|
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
||||||
|
LogError("HTTP", "Domain: %s", req.URL.Host)
|
||||||
|
LogError("HTTP", "Response contains: %s", indicator)
|
||||||
|
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||||
|
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server errors (5xx) - retry
|
// Server errors (5xx) - retry
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||||
if attempt < config.MaxRetries {
|
if attempt < config.MaxRetries {
|
||||||
|
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
delay = calculateNextDelay(delay, config)
|
delay = calculateNextDelay(delay, config)
|
||||||
}
|
}
|
||||||
@@ -211,3 +344,172 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
|||||||
}
|
}
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISPBlockingError represents an error caused by ISP blocking
|
||||||
|
type ISPBlockingError struct {
|
||||||
|
Domain string
|
||||||
|
Reason string
|
||||||
|
OriginalErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ISPBlockingError) Error() string {
|
||||||
|
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
||||||
|
// Returns the ISPBlockingError if detected, nil otherwise
|
||||||
|
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract domain from URL
|
||||||
|
domain := extractDomain(requestURL)
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
|
||||||
|
// Check for DNS resolution failure (common ISP blocking method)
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) {
|
||||||
|
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for connection refused (ISP firewall blocking)
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
if opErr.Op == "dial" {
|
||||||
|
// Check for specific syscall errors
|
||||||
|
var syscallErr syscall.Errno
|
||||||
|
if errors.As(opErr.Err, &syscallErr) {
|
||||||
|
switch syscallErr {
|
||||||
|
case syscall.ECONNREFUSED:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ECONNRESET:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection reset - ISP may be intercepting traffic",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ETIMEDOUT:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Connection timed out - ISP may be blocking access",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.ENETUNREACH:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Network unreachable - ISP may be blocking route",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
case syscall.EHOSTUNREACH:
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "Host unreachable - ISP may be blocking destination",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
||||||
|
var tlsErr *tls.RecordHeaderError
|
||||||
|
if errors.As(err, &tlsErr) {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check error message patterns for common ISP blocking indicators
|
||||||
|
blockingPatterns := []struct {
|
||||||
|
pattern string
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
||||||
|
{"connection refused", "Connection refused - port may be blocked"},
|
||||||
|
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
||||||
|
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
||||||
|
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
||||||
|
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
||||||
|
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
||||||
|
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
||||||
|
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bp := range blockingPatterns {
|
||||||
|
if strings.Contains(errStr, bp.pattern) {
|
||||||
|
return &ISPBlockingError{
|
||||||
|
Domain: domain,
|
||||||
|
Reason: bp.reason,
|
||||||
|
OriginalErr: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
|
||||||
|
// Returns true if ISP blocking was detected
|
||||||
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
|
ispErr := IsISPBlocking(err, requestURL)
|
||||||
|
if ispErr != nil {
|
||||||
|
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
|
||||||
|
LogError(tag, "Domain: %s", ispErr.Domain)
|
||||||
|
LogError(tag, "Reason: %s", ispErr.Reason)
|
||||||
|
LogError(tag, "Original error: %v", ispErr.OriginalErr)
|
||||||
|
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDomain extracts the domain from a URL string
|
||||||
|
func extractDomain(rawURL string) string {
|
||||||
|
if rawURL == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
// Try to extract domain manually
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||||
|
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||||
|
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||||
|
return rawURL[:idx]
|
||||||
|
}
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Host != "" {
|
||||||
|
return parsed.Host
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
|
||||||
|
// If ISP blocking is detected, returns a more descriptive error
|
||||||
|
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
||||||
|
domain := extractDomain(requestURL)
|
||||||
|
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry represents a single log entry
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
|
||||||
|
type LogBuffer struct {
|
||||||
|
entries []LogEntry
|
||||||
|
maxSize int
|
||||||
|
mu sync.RWMutex
|
||||||
|
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalLogBuffer *LogBuffer
|
||||||
|
logBufferOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetLogBuffer returns the singleton log buffer instance
|
||||||
|
func GetLogBuffer() *LogBuffer {
|
||||||
|
logBufferOnce.Do(func() {
|
||||||
|
globalLogBuffer = &LogBuffer{
|
||||||
|
entries: make([]LogEntry, 0, 500),
|
||||||
|
maxSize: 500,
|
||||||
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalLogBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoggingEnabled enables or disables logging
|
||||||
|
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
lb.loggingEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLoggingEnabled returns whether logging is enabled
|
||||||
|
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
return lb.loggingEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds a log entry to the buffer
|
||||||
|
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
|
||||||
|
// Skip if logging is disabled (except for errors which are always logged)
|
||||||
|
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := LogEntry{
|
||||||
|
Timestamp: time.Now().Format("15:04:05.000"),
|
||||||
|
Level: level,
|
||||||
|
Tag: tag,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lb.entries) >= lb.maxSize {
|
||||||
|
// Remove oldest entry
|
||||||
|
lb.entries = lb.entries[1:]
|
||||||
|
}
|
||||||
|
lb.entries = append(lb.entries, entry)
|
||||||
|
|
||||||
|
// Also print to logcat for debugging
|
||||||
|
fmt.Printf("[%s] %s\n", tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns all log entries as JSON
|
||||||
|
func (lb *LogBuffer) GetAll() string {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
|
||||||
|
jsonBytes, _ := json.Marshal(lb.entries)
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSince returns log entries since the given index (internal use)
|
||||||
|
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
if index >= len(lb.entries) {
|
||||||
|
return []LogEntry{}, len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := lb.entries[index:]
|
||||||
|
return entries, len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear clears all log entries
|
||||||
|
func (lb *LogBuffer) Clear() {
|
||||||
|
lb.mu.Lock()
|
||||||
|
defer lb.mu.Unlock()
|
||||||
|
lb.entries = lb.entries[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of log entries
|
||||||
|
func (lb *LogBuffer) Count() int {
|
||||||
|
lb.mu.RLock()
|
||||||
|
defer lb.mu.RUnlock()
|
||||||
|
return len(lb.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for logging with different levels
|
||||||
|
func LogDebug(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogInfo(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogWarn(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogError(tag, format string, args ...interface{}) {
|
||||||
|
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
||||||
|
// It parses the tag from the format string if it starts with [Tag]
|
||||||
|
func GoLog(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
message = strings.TrimSuffix(message, "\n")
|
||||||
|
|
||||||
|
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||||
|
tag := "Go"
|
||||||
|
level := "INFO"
|
||||||
|
|
||||||
|
if strings.HasPrefix(message, "[") {
|
||||||
|
endBracket := strings.Index(message, "]")
|
||||||
|
if endBracket > 1 {
|
||||||
|
tag = message[1:endBracket]
|
||||||
|
message = strings.TrimSpace(message[endBracket+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine level from message content
|
||||||
|
msgLower := strings.ToLower(message)
|
||||||
|
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
||||||
|
level = "ERROR"
|
||||||
|
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||||
|
level = "WARN"
|
||||||
|
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||||
|
level = "INFO"
|
||||||
|
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||||
|
level = "DEBUG"
|
||||||
|
}
|
||||||
|
|
||||||
|
GetLogBuffer().Add(level, tag, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported functions for Flutter
|
||||||
|
|
||||||
|
// GetLogs returns all logs as JSON array
|
||||||
|
func GetLogs() string {
|
||||||
|
return GetLogBuffer().GetAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogsSince returns logs since the given index
|
||||||
|
// Returns JSON: {"logs": [...], "next_index": N}
|
||||||
|
func GetLogsSince(index int) string {
|
||||||
|
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||||
|
logsJson, _ := json.Marshal(entries)
|
||||||
|
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLogs clears all logs
|
||||||
|
func ClearLogs() {
|
||||||
|
GetLogBuffer().Clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogCount returns the number of log entries
|
||||||
|
func GetLogCount() int {
|
||||||
|
return GetLogBuffer().Count()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoggingEnabled enables or disables logging from Flutter
|
||||||
|
func SetLoggingEnabled(enabled bool) {
|
||||||
|
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||||
|
}
|
||||||
@@ -248,6 +248,8 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||||
|
// Use convertToLRCWithMetadata for full LRC with headers
|
||||||
func convertToLRC(lyrics *LyricsResponse) string {
|
func convertToLRC(lyrics *LyricsResponse) string {
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -272,6 +274,45 @@ func convertToLRC(lyrics *LyricsResponse) 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 {
|
||||||
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
// Add metadata headers
|
||||||
|
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
|
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
|
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||||
|
builder.WriteString("\n")
|
||||||
|
|
||||||
|
// Add lyrics lines
|
||||||
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
|
for _, line := range lyrics.Lines {
|
||||||
|
if line.Words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||||
|
builder.WriteString(timestamp)
|
||||||
|
builder.WriteString(line.Words)
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, line := range lyrics.Lines {
|
||||||
|
if line.Words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
builder.WriteString(line.Words)
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
func simplifyTrackName(name string) string {
|
func simplifyTrackName(name string) string {
|
||||||
patterns := []string{
|
patterns := []string{
|
||||||
`\s*\(feat\..*?\)`,
|
`\s*\(feat\..*?\)`,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
@@ -57,7 +58,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
if metadata.TotalTracks > 0 {
|
if metadata.TotalTracks > 0 {
|
||||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||||
@@ -65,15 +66,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
setComment(cmt, "ISRC", metadata.ISRC)
|
setComment(cmt, "ISRC", metadata.ISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Description != "" {
|
if metadata.Description != "" {
|
||||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||||
}
|
}
|
||||||
@@ -104,7 +105,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picture, err := flacpicture.NewFromImageData(
|
||||||
flacpicture.PictureTypeFrontCover,
|
flacpicture.PictureTypeFrontCover,
|
||||||
"Front Cover",
|
"Front Cover",
|
||||||
@@ -161,7 +162,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
if metadata.TotalTracks > 0 {
|
if metadata.TotalTracks > 0 {
|
||||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||||
@@ -169,15 +170,15 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
setComment(cmt, "ISRC", metadata.ISRC)
|
setComment(cmt, "ISRC", metadata.ISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Description != "" {
|
if metadata.Description != "" {
|
||||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||||
}
|
}
|
||||||
@@ -203,7 +204,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picture, err := flacpicture.NewFromImageData(
|
||||||
flacpicture.PictureTypeFrontCover,
|
flacpicture.PictureTypeFrontCover,
|
||||||
"Front Cover",
|
"Front Cover",
|
||||||
@@ -256,11 +257,30 @@ 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 {
|
||||||
|
trackNum = getComment(cmt, "TRACK")
|
||||||
|
if trackNum != "" {
|
||||||
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
discNum := getComment(cmt, "DISCNUMBER")
|
discNum := getComment(cmt, "DISCNUMBER")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
}
|
}
|
||||||
|
// Also try DISC variant
|
||||||
|
if metadata.DiscNumber == 0 {
|
||||||
|
discNum = getComment(cmt, "DISC")
|
||||||
|
if discNum != "" {
|
||||||
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try DATE variants
|
||||||
|
if metadata.Date == "" {
|
||||||
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -273,10 +293,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing
|
// Remove existing (case-insensitive comparison for Vorbis comments)
|
||||||
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
|
comment := cmt.Comments[i]
|
||||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
eqIdx := strings.Index(comment, "=")
|
||||||
|
if eqIdx > 0 {
|
||||||
|
existingKey := strings.ToUpper(comment[:eqIdx])
|
||||||
|
if existingKey == keyUpper {
|
||||||
|
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add new
|
// Add new
|
||||||
@@ -284,9 +310,14 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||||
|
keyUpper := strings.ToUpper(key) + "="
|
||||||
for _, comment := range cmt.Comments {
|
for _, comment := range cmt.Comments {
|
||||||
if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" {
|
if len(comment) > len(key) {
|
||||||
return comment[len(key)+1:]
|
// Case-insensitive comparison for Vorbis comments
|
||||||
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||||
|
if commentUpper == keyUpper {
|
||||||
|
return comment[len(key)+1:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -335,3 +366,457 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
|||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||||
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.VorbisComment {
|
||||||
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try LYRICS tag first
|
||||||
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to UNSYNCEDLYRICS
|
||||||
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioQuality represents audio quality info from a FLAC file
|
||||||
|
type AudioQuality struct {
|
||||||
|
BitDepth int `json:"bit_depth"`
|
||||||
|
SampleRate int `json:"sample_rate"`
|
||||||
|
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) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Read first 4 bytes to detect file type
|
||||||
|
marker := make([]byte, 4)
|
||||||
|
if _, err := file.Read(marker); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a FLAC file
|
||||||
|
if string(marker) == "fLaC" {
|
||||||
|
// Continue reading FLAC metadata
|
||||||
|
// Read metadata block header (4 bytes)
|
||||||
|
header := make([]byte, 4)
|
||||||
|
if _, err := file.Read(header); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockType := header[0] & 0x7F
|
||||||
|
if blockType != 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read STREAMINFO block (34 bytes minimum)
|
||||||
|
streamInfo := make([]byte, 34)
|
||||||
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Parse bits per sample (5 bits)
|
||||||
|
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 |
|
||||||
|
int64(streamInfo[14])<<24 |
|
||||||
|
int64(streamInfo[15])<<16 |
|
||||||
|
int64(streamInfo[16])<<8 |
|
||||||
|
int64(streamInfo[17])
|
||||||
|
|
||||||
|
return AudioQuality{
|
||||||
|
BitDepth: bitsPerSample,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
TotalSamples: totalSamples,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
||||||
|
// First 4 bytes are size, next 4 should be "ftyp"
|
||||||
|
file.Seek(0, 0) // Reset to beginning
|
||||||
|
header8 := make([]byte, 8)
|
||||||
|
if _, err := file.Read(header8); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(header8[4:8]) == "ftyp" {
|
||||||
|
// It's an M4A/MP4 file, use M4A quality reader
|
||||||
|
file.Close() // Close before calling GetM4AQuality which opens the file again
|
||||||
|
return GetM4AQuality(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// M4A (MP4/AAC) Metadata Embedding
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||||
|
// This is a simplified implementation that writes metadata to the file
|
||||||
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
|
// Read the entire file
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find moov atom position
|
||||||
|
moovPos := findAtom(data, "moov", 0)
|
||||||
|
if moovPos < 0 {
|
||||||
|
return fmt.Errorf("moov atom not found in M4A file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find udta atom inside moov, or create one
|
||||||
|
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
|
||||||
|
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||||
|
|
||||||
|
// Build new metadata atoms
|
||||||
|
metaAtom := buildMetaAtom(metadata, coverData)
|
||||||
|
|
||||||
|
var newData []byte
|
||||||
|
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||||
|
// udta exists, find meta inside it or replace
|
||||||
|
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
|
||||||
|
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||||
|
|
||||||
|
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||||
|
// Replace existing meta atom
|
||||||
|
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
|
||||||
|
newData = append(newData, data[:metaPos]...)
|
||||||
|
newData = append(newData, metaAtom...)
|
||||||
|
newData = append(newData, data[metaPos+metaSize:]...)
|
||||||
|
} else {
|
||||||
|
// Add meta atom to udta
|
||||||
|
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||||
|
newUdtaSize := 8 + len(newUdtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(newUdtaSize >> 24)
|
||||||
|
newUdta[1] = byte(newUdtaSize >> 16)
|
||||||
|
newUdta[2] = byte(newUdtaSize >> 8)
|
||||||
|
newUdta[3] = byte(newUdtaSize)
|
||||||
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
|
newUdta = append(newUdta, newUdtaContent...)
|
||||||
|
|
||||||
|
newData = append(newData, data[:udtaPos]...)
|
||||||
|
newData = append(newData, newUdta...)
|
||||||
|
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new udta with meta
|
||||||
|
udtaContent := metaAtom
|
||||||
|
udtaSize := 8 + len(udtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(udtaSize >> 24)
|
||||||
|
newUdta[1] = byte(udtaSize >> 16)
|
||||||
|
newUdta[2] = byte(udtaSize >> 8)
|
||||||
|
newUdta[3] = byte(udtaSize)
|
||||||
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
|
newUdta = append(newUdta, udtaContent...)
|
||||||
|
|
||||||
|
// Insert udta at end of moov
|
||||||
|
insertPos := moovPos + moovSize
|
||||||
|
newData = append(newData, data[:insertPos]...)
|
||||||
|
newData = append(newData, newUdta...)
|
||||||
|
newData = append(newData, data[insertPos:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update moov size
|
||||||
|
newMoovSize := moovSize + len(newData) - len(data)
|
||||||
|
newData[moovPos] = byte(newMoovSize >> 24)
|
||||||
|
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||||
|
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||||
|
newData[moovPos+3] = byte(newMoovSize)
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAtom finds an atom by name starting from offset
|
||||||
|
func findAtom(data []byte, name string, offset int) int {
|
||||||
|
for i := offset; i < len(data)-8; {
|
||||||
|
size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
|
||||||
|
if size < 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
atomName := string(data[i+4 : i+8])
|
||||||
|
if atomName == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||||
|
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||||
|
// Build ilst content
|
||||||
|
var ilst []byte
|
||||||
|
|
||||||
|
// ©nam - Title
|
||||||
|
if metadata.Title != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©ART - Artist
|
||||||
|
if metadata.Artist != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©alb - Album
|
||||||
|
if metadata.Album != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aART - Album Artist
|
||||||
|
if metadata.AlbumArtist != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©day - Year/Date
|
||||||
|
if metadata.Date != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trkn - Track Number
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk - Disc Number
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©lyr - Lyrics
|
||||||
|
if metadata.Lyrics != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// covr - Cover Art
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ilst atom
|
||||||
|
ilstSize := 8 + len(ilst)
|
||||||
|
ilstAtom := make([]byte, 4)
|
||||||
|
ilstAtom[0] = byte(ilstSize >> 24)
|
||||||
|
ilstAtom[1] = byte(ilstSize >> 16)
|
||||||
|
ilstAtom[2] = byte(ilstSize >> 8)
|
||||||
|
ilstAtom[3] = byte(ilstSize)
|
||||||
|
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||||
|
ilstAtom = append(ilstAtom, ilst...)
|
||||||
|
|
||||||
|
// Build hdlr atom (required for meta)
|
||||||
|
hdlr := []byte{
|
||||||
|
0, 0, 0, 33, // size = 33
|
||||||
|
'h', 'd', 'l', 'r',
|
||||||
|
0, 0, 0, 0, // version + flags
|
||||||
|
0, 0, 0, 0, // predefined
|
||||||
|
'm', 'd', 'i', 'r', // handler type
|
||||||
|
'a', 'p', 'p', 'l', // manufacturer
|
||||||
|
0, 0, 0, 0, // component flags
|
||||||
|
0, 0, 0, 0, // component flags mask
|
||||||
|
0, // null terminator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build meta atom
|
||||||
|
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||||
|
metaContent = append(metaContent, ilstAtom...)
|
||||||
|
|
||||||
|
metaSize := 8 + len(metaContent)
|
||||||
|
metaAtom := make([]byte, 4)
|
||||||
|
metaAtom[0] = byte(metaSize >> 24)
|
||||||
|
metaAtom[1] = byte(metaSize >> 16)
|
||||||
|
metaAtom[2] = byte(metaSize >> 8)
|
||||||
|
metaAtom[3] = byte(metaSize)
|
||||||
|
metaAtom = append(metaAtom, []byte("meta")...)
|
||||||
|
metaAtom = append(metaAtom, metaContent...)
|
||||||
|
|
||||||
|
return metaAtom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||||
|
func buildTextAtom(name, value string) []byte {
|
||||||
|
valueBytes := []byte(value)
|
||||||
|
|
||||||
|
// data atom
|
||||||
|
dataSize := 16 + len(valueBytes)
|
||||||
|
dataAtom := make([]byte, 4)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
|
dataAtom = append(dataAtom, valueBytes...)
|
||||||
|
|
||||||
|
// container atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte(name)...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTrackNumberAtom builds trkn atom
|
||||||
|
func buildTrackNumberAtom(track, total int) []byte {
|
||||||
|
// data atom with track number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 24, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(track >> 8), byte(track), // track number
|
||||||
|
byte(total >> 8), byte(total), // total tracks
|
||||||
|
0, 0, // padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// trkn atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("trkn")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiscNumberAtom builds disk atom
|
||||||
|
func buildDiscNumberAtom(disc, total int) []byte {
|
||||||
|
// data atom with disc number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 22, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(disc >> 8), byte(disc), // disc number
|
||||||
|
byte(total >> 8), byte(total), // total discs
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("disk")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCoverAtom builds covr atom with image data
|
||||||
|
func buildCoverAtom(coverData []byte) []byte {
|
||||||
|
// Detect image type (JPEG = 13, PNG = 14)
|
||||||
|
imageType := byte(13) // default JPEG
|
||||||
|
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||||
|
imageType = 14 // PNG
|
||||||
|
}
|
||||||
|
|
||||||
|
// data atom
|
||||||
|
dataSize := 16 + len(coverData)
|
||||||
|
dataAtom := make([]byte, 4)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
|
dataAtom = append(dataAtom, coverData...)
|
||||||
|
|
||||||
|
// covr atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("covr")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetM4AQuality reads audio quality from M4A file
|
||||||
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
||||||
|
moovPos := findAtom(data, "moov", 0)
|
||||||
|
if moovPos < 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for mp4a or alac atom which contains audio info
|
||||||
|
// This is a simplified search - real implementation would traverse the atom tree
|
||||||
|
for i := moovPos; i < len(data)-20; i++ {
|
||||||
|
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||||
|
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
||||||
|
if i+24 < len(data) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ISRC to Track ID Cache
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// TrackIDCacheEntry holds cached track ID with metadata
|
||||||
|
type TrackIDCacheEntry struct {
|
||||||
|
TidalTrackID int64
|
||||||
|
QobuzTrackID int64
|
||||||
|
AmazonTrackID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackIDCache caches ISRC to track ID mappings
|
||||||
|
type TrackIDCache struct {
|
||||||
|
cache map[string]*TrackIDCacheEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalTrackIDCache *TrackIDCache
|
||||||
|
trackIDCacheOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTrackIDCache returns the global track ID cache
|
||||||
|
func GetTrackIDCache() *TrackIDCache {
|
||||||
|
trackIDCacheOnce.Do(func() {
|
||||||
|
globalTrackIDCache = &TrackIDCache{
|
||||||
|
cache: make(map[string]*TrackIDCacheEntry),
|
||||||
|
ttl: 30 * time.Minute, // Cache for 30 minutes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalTrackIDCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a cached entry by ISRC
|
||||||
|
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists || time.Now().After(entry.ExpiresAt) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTidal caches Tidal track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.TidalTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQobuz caches Qobuz track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.QobuzTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAmazon caches Amazon track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.AmazonTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all cached entries
|
||||||
|
func (c *TrackIDCache) Clear() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the number of cached entries
|
||||||
|
func (c *TrackIDCache) Size() int {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return len(c.cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Parallel Download Helper
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ParallelDownloadResult holds results from parallel operations
|
||||||
|
type ParallelDownloadResult struct {
|
||||||
|
CoverData []byte
|
||||||
|
LyricsData *LyricsResponse
|
||||||
|
LyricsLRC string
|
||||||
|
CoverErr error
|
||||||
|
LyricsErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
||||||
|
// This runs while the main audio download is happening
|
||||||
|
func FetchCoverAndLyricsParallel(
|
||||||
|
coverURL string,
|
||||||
|
maxQualityCover bool,
|
||||||
|
spotifyID string,
|
||||||
|
trackName string,
|
||||||
|
artistName string,
|
||||||
|
embedLyrics bool,
|
||||||
|
) *ParallelDownloadResult {
|
||||||
|
result := &ParallelDownloadResult{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Download cover in parallel
|
||||||
|
if coverURL != "" {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
fmt.Println("[Parallel] Starting cover download...")
|
||||||
|
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||||
|
if err != nil {
|
||||||
|
result.CoverErr = err
|
||||||
|
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
result.CoverData = data
|
||||||
|
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch lyrics in parallel
|
||||||
|
if embedLyrics {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
fmt.Println("[Parallel] Starting lyrics fetch...")
|
||||||
|
client := NewLyricsClient()
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
result.LyricsErr = err
|
||||||
|
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||||
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
|
result.LyricsData = lyrics
|
||||||
|
// Use LRC with metadata headers (like PC version)
|
||||||
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
|
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||||
|
} else {
|
||||||
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||||
|
fmt.Println("[Parallel] No lyrics found")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Pre-warm Cache for Album/Playlist
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// PreWarmCacheRequest represents a track to pre-warm cache for
|
||||||
|
type PreWarmCacheRequest struct {
|
||||||
|
ISRC string
|
||||||
|
TrackName string
|
||||||
|
ArtistName string
|
||||||
|
SpotifyID string // Needed for Amazon (SongLink lookup)
|
||||||
|
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) {
|
||||||
|
if len(requests) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
||||||
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
|
// Limit concurrent pre-warm requests
|
||||||
|
semaphore := make(chan struct{}, 3) // Max 3 concurrent
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, req := range requests {
|
||||||
|
// Skip if already cached
|
||||||
|
if cached := cache.Get(req.ISRC); cached != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func(r PreWarmCacheRequest) {
|
||||||
|
defer wg.Done()
|
||||||
|
semaphore <- struct{}{} // Acquire
|
||||||
|
defer func() { <-semaphore }() // Release
|
||||||
|
|
||||||
|
switch r.Service {
|
||||||
|
case "tidal":
|
||||||
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||||
|
case "qobuz":
|
||||||
|
preWarmQobuzCache(r.ISRC)
|
||||||
|
case "amazon":
|
||||||
|
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||||
|
}
|
||||||
|
}(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmTidalCache(isrc, trackName, artistName string) {
|
||||||
|
downloader := NewTidalDownloader()
|
||||||
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||||
|
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmQobuzCache(isrc string) {
|
||||||
|
downloader := NewQobuzDownloader()
|
||||||
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||||
|
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||||
|
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
|
if err == nil && availability != nil && availability.Amazon {
|
||||||
|
// Store Amazon URL in cache (using ISRC as key)
|
||||||
|
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||||
|
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Exported Functions for Flutter
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
|
||||||
|
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
||||||
|
func PreWarmCache(tracksJSON string) error {
|
||||||
|
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
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTrackCache clears the track ID cache
|
||||||
|
func ClearTrackCache() {
|
||||||
|
GetTrackIDCache().Clear()
|
||||||
|
fmt.Println("[Cache] Track ID cache cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCacheSize returns the current cache size
|
||||||
|
func GetCacheSize() int {
|
||||||
|
return GetTrackIDCache().Size()
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress
|
// 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"`
|
||||||
@@ -12,54 +15,184 @@ 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemProgress represents progress for a single download item
|
||||||
|
type ItemProgress struct {
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
|
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||||
|
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||||
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiProgress holds progress for multiple concurrent downloads
|
||||||
|
type MultiProgress struct {
|
||||||
|
Items map[string]*ItemProgress `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
currentProgress DownloadProgress
|
downloadDir string
|
||||||
progressMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
downloadDir string
|
|
||||||
downloadDirMu sync.RWMutex
|
// Multi-download progress tracking (unified system)
|
||||||
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
|
multiMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// getProgress returns current download progress
|
// getProgress returns current download progress from multi-progress system
|
||||||
|
// Returns first active item's progress for backward compatibility
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
progressMu.RLock()
|
multiMu.RLock()
|
||||||
defer progressMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
return currentProgress
|
|
||||||
|
// Find first active item
|
||||||
|
for _, item := range multiProgress.Items {
|
||||||
|
return DownloadProgress{
|
||||||
|
CurrentFile: item.ItemID,
|
||||||
|
Progress: item.Progress * 100, // Convert to percentage
|
||||||
|
BytesTotal: item.BytesTotal,
|
||||||
|
BytesReceived: item.BytesReceived,
|
||||||
|
IsDownloading: item.IsDownloading,
|
||||||
|
Status: item.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadProgress{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadProgress sets the current download progress (MB downloaded)
|
// GetMultiProgress returns progress for all active downloads as JSON
|
||||||
func SetDownloadProgress(mbDownloaded float64) {
|
func GetMultiProgress() string {
|
||||||
progressMu.Lock()
|
multiMu.RLock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.RUnlock()
|
||||||
currentProgress.Progress = mbDownloaded
|
|
||||||
currentProgress.IsDownloading = true
|
jsonBytes, err := json.Marshal(multiProgress)
|
||||||
|
if err != nil {
|
||||||
|
return "{\"items\":{}}"
|
||||||
|
}
|
||||||
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadSpeed sets the current download speed
|
// GetItemProgress returns progress for a specific item as JSON
|
||||||
func SetDownloadSpeed(speedMBps float64) {
|
func GetItemProgress(itemID string) string {
|
||||||
progressMu.Lock()
|
multiMu.RLock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.RUnlock()
|
||||||
currentProgress.Speed = speedMBps
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
jsonBytes, _ := json.Marshal(item)
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
return "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCurrentFile sets the current file being downloaded and resets progress
|
// StartItemProgress initializes progress tracking for an item
|
||||||
func SetCurrentFile(filename string) {
|
func StartItemProgress(itemID string) {
|
||||||
progressMu.Lock()
|
multiMu.Lock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
// Reset progress for new file
|
|
||||||
currentProgress.BytesReceived = 0
|
multiProgress.Items[itemID] = &ItemProgress{
|
||||||
currentProgress.BytesTotal = 0
|
ItemID: itemID,
|
||||||
currentProgress.Progress = 0
|
BytesTotal: 0,
|
||||||
currentProgress.CurrentFile = filename
|
BytesReceived: 0,
|
||||||
currentProgress.IsDownloading = true
|
Progress: 0,
|
||||||
|
IsDownloading: true,
|
||||||
|
Status: "downloading",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetProgress resets the download progress
|
// SetItemBytesTotal sets total bytes for an item
|
||||||
func ResetProgress() {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
progressMu.Lock()
|
multiMu.Lock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
currentProgress = DownloadProgress{}
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesTotal = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemBytesReceived sets bytes received for an item
|
||||||
|
func SetItemBytesReceived(itemID string, received int64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesReceived = received
|
||||||
|
if item.BytesTotal > 0 {
|
||||||
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
||||||
|
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesReceived = received
|
||||||
|
item.SpeedMBps = speedMBps
|
||||||
|
if item.BytesTotal > 0 {
|
||||||
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteItemProgress marks an item as complete
|
||||||
|
func CompleteItemProgress(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = 1.0
|
||||||
|
item.IsDownloading = false
|
||||||
|
item.Status = "completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemProgress sets progress for an item directly
|
||||||
|
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = progress
|
||||||
|
if bytesReceived > 0 {
|
||||||
|
item.BytesReceived = bytesReceived
|
||||||
|
}
|
||||||
|
if bytesTotal > 0 {
|
||||||
|
item.BytesTotal = bytesTotal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||||
|
func SetItemFinalizing(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = 1.0
|
||||||
|
item.Status = "finalizing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItemProgress removes progress tracking for an item
|
||||||
|
func RemoveItemProgress(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
delete(multiProgress.Items, itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAllItemProgress clears all item progress
|
||||||
|
func ClearAllItemProgress() {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDownloadDir sets the default download directory
|
// setDownloadDir sets the default download directory
|
||||||
@@ -77,61 +210,57 @@ func getDownloadDir() string {
|
|||||||
return downloadDir
|
return downloadDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloading sets the download status
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
func SetDownloading(status bool) {
|
type ItemProgressWriter struct {
|
||||||
progressMu.Lock()
|
writer interface{ Write([]byte) (int, error) }
|
||||||
defer progressMu.Unlock()
|
itemID string
|
||||||
currentProgress.IsDownloading = status
|
current int64
|
||||||
|
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||||
|
startTime time.Time // Track start time for speed calculation
|
||||||
|
lastTime time.Time // Track last update time for speed calculation
|
||||||
|
lastBytes int64 // Track bytes at last speed calculation
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBytesTotal sets total bytes to download
|
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||||
func SetBytesTotal(total int64) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.BytesTotal = total
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBytesReceived sets bytes received so far
|
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||||
func SetBytesReceived(received int64) {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
progressMu.Lock()
|
now := time.Now()
|
||||||
defer progressMu.Unlock()
|
return &ItemProgressWriter{
|
||||||
currentProgress.BytesReceived = received
|
writer: w,
|
||||||
if currentProgress.BytesTotal > 0 {
|
itemID: itemID,
|
||||||
currentProgress.Progress = float64(received) / float64(currentProgress.BytesTotal) * 100
|
current: 0,
|
||||||
|
lastReported: 0,
|
||||||
|
startTime: now,
|
||||||
|
lastTime: now,
|
||||||
|
lastBytes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProgressWriter wraps io.Writer to track download progress
|
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||||
type ProgressWriter struct {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
|
||||||
total int64
|
|
||||||
current int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewProgressWriter creates a new progress writer wrapping an io.Writer
|
|
||||||
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
|
|
||||||
// Reset bytes received when starting new download
|
|
||||||
SetBytesReceived(0)
|
|
||||||
return &ProgressWriter{
|
|
||||||
writer: w,
|
|
||||||
current: 0,
|
|
||||||
total: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write implements io.Writer
|
|
||||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
pw.total += int64(n)
|
|
||||||
SetBytesReceived(pw.current)
|
// 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 {
|
||||||
|
// Calculate speed (MB/s) based on bytes received since last update
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||||
|
var speedMBps float64
|
||||||
|
if elapsed > 0 {
|
||||||
|
bytesInInterval := pw.current - pw.lastBytes
|
||||||
|
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
|
||||||
|
pw.lastReported = pw.current
|
||||||
|
pw.lastTime = now
|
||||||
|
pw.lastBytes = pw.current
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTotal returns total bytes written
|
|
||||||
func (pw *ProgressWriter) GetTotal() int64 {
|
|
||||||
return pw.total
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,6 +10,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
// QobuzDownloader handles Qobuz downloads
|
||||||
@@ -18,6 +21,12 @@ type QobuzDownloader struct {
|
|||||||
apiURL string
|
apiURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Global Qobuz downloader instance for connection reuse
|
||||||
|
globalQobuzDownloader *QobuzDownloader
|
||||||
|
qobuzDownloaderOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
// QobuzTrack represents a Qobuz track
|
// QobuzTrack represents a Qobuz track
|
||||||
type QobuzTrack struct {
|
type QobuzTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -39,12 +48,257 @@ type QobuzTrack struct {
|
|||||||
} `json:"performer"`
|
} `json:"performer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader
|
// qobuzArtistsMatch checks if the artist names are similar enough
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
return &QobuzDownloader{
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
appID: "798273057",
|
|
||||||
|
// 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 TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||||
|
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||||
|
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
||||||
|
foundLatin := qobuzIsLatinScript(foundArtist)
|
||||||
|
if expectedLatin != foundLatin {
|
||||||
|
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzTitlesMatch checks if track titles are similar enough
|
||||||
|
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||||
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||||
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
|
||||||
|
cleanExpected := qobuzCleanTitle(normExpected)
|
||||||
|
cleanFound := qobuzCleanTitle(normFound)
|
||||||
|
|
||||||
|
if cleanExpected == cleanFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cleaned versions contain each other
|
||||||
|
if cleanExpected != "" && cleanFound != "" {
|
||||||
|
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract core title (before any parentheses/brackets)
|
||||||
|
coreExpected := qobuzExtractCoreTitle(normExpected)
|
||||||
|
coreFound := qobuzExtractCoreTitle(normFound)
|
||||||
|
|
||||||
|
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||||
|
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||||
|
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
||||||
|
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||||
|
if expectedLatin != foundLatin {
|
||||||
|
GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
|
||||||
|
func qobuzExtractCoreTitle(title string) string {
|
||||||
|
// Find first occurrence of ( or [
|
||||||
|
parenIdx := strings.Index(title, "(")
|
||||||
|
bracketIdx := strings.Index(title, "[")
|
||||||
|
dashIdx := strings.Index(title, " - ")
|
||||||
|
|
||||||
|
cutIdx := len(title)
|
||||||
|
if parenIdx > 0 && parenIdx < cutIdx {
|
||||||
|
cutIdx = parenIdx
|
||||||
|
}
|
||||||
|
if bracketIdx > 0 && bracketIdx < cutIdx {
|
||||||
|
cutIdx = bracketIdx
|
||||||
|
}
|
||||||
|
if dashIdx > 0 && dashIdx < cutIdx {
|
||||||
|
cutIdx = dashIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(title[:cutIdx])
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzCleanTitle removes common suffixes from track titles for comparison
|
||||||
|
func qobuzCleanTitle(title string) string {
|
||||||
|
cleaned := title
|
||||||
|
|
||||||
|
// Remove content in parentheses/brackets that are version indicators
|
||||||
|
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
|
||||||
|
versionPatterns := []string{
|
||||||
|
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||||
|
"album version", "radio edit", "original mix", "extended",
|
||||||
|
"club mix", "remix", "live", "acoustic", "demo",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove parenthetical content if it contains version indicators
|
||||||
|
for {
|
||||||
|
startParen := strings.LastIndex(cleaned, "(")
|
||||||
|
endParen := strings.LastIndex(cleaned, ")")
|
||||||
|
if startParen >= 0 && endParen > startParen {
|
||||||
|
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
||||||
|
isVersionIndicator := false
|
||||||
|
for _, pattern := range versionPatterns {
|
||||||
|
if strings.Contains(content, pattern) {
|
||||||
|
isVersionIndicator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isVersionIndicator {
|
||||||
|
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same for brackets
|
||||||
|
for {
|
||||||
|
startBracket := strings.LastIndex(cleaned, "[")
|
||||||
|
endBracket := strings.LastIndex(cleaned, "]")
|
||||||
|
if startBracket >= 0 && endBracket > startBracket {
|
||||||
|
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
||||||
|
isVersionIndicator := false
|
||||||
|
for _, pattern := range versionPatterns {
|
||||||
|
if strings.Contains(content, pattern) {
|
||||||
|
isVersionIndicator = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isVersionIndicator {
|
||||||
|
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing " - version" patterns
|
||||||
|
dashPatterns := []string{
|
||||||
|
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||||
|
" - live", " - acoustic", " - demo", " - remix",
|
||||||
|
}
|
||||||
|
for _, pattern := range dashPatterns {
|
||||||
|
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
||||||
|
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
for strings.Contains(cleaned, " ") {
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzIsLatinScript checks if a string is primarily Latin script
|
||||||
|
// Returns true for ASCII and Latin Extended characters (European languages)
|
||||||
|
// Returns false for CJK, Arabic, Cyrillic, etc.
|
||||||
|
func qobuzIsLatinScript(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
// Skip common punctuation and numbers
|
||||||
|
if r < 128 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
||||||
|
// Latin Extended-B: U+0180 to U+024F
|
||||||
|
// Latin Extended Additional: U+1E00 to U+1EFF
|
||||||
|
// Latin Extended-C/D/E: various ranges
|
||||||
|
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
||||||
|
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
||||||
|
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// CJK ranges - definitely different script
|
||||||
|
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
||||||
|
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
||||||
|
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
||||||
|
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
||||||
|
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func qobuzIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsQueryQobuz checks if a query already exists in the list
|
||||||
|
func containsQueryQobuz(queries []string, query string) bool {
|
||||||
|
for _, q := range queries {
|
||||||
|
if q == query {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
||||||
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
|
qobuzDownloaderOnce.Do(func() {
|
||||||
|
globalQobuzDownloader = &QobuzDownloader{
|
||||||
|
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||||
|
appID: "798273057",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalQobuzDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||||
@@ -53,8 +307,8 @@ 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
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
"ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC)
|
||||||
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
|
"ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC)
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -112,11 +366,107 @@ 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)
|
||||||
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||||
|
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []QobuzTrack `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||||
|
|
||||||
|
// Find ISRC matches
|
||||||
|
var isrcMatches []*QobuzTrack
|
||||||
|
for i := range result.Tracks.Items {
|
||||||
|
if result.Tracks.Items[i].ISRC == isrc {
|
||||||
|
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||||
|
|
||||||
|
if len(isrcMatches) > 0 {
|
||||||
|
// Verify duration if provided
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationVerifiedMatches []*QobuzTrack
|
||||||
|
for _, track := range isrcMatches {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 10 seconds tolerance
|
||||||
|
if durationDiff <= 10 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||||
|
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",
|
||||||
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||||
|
expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration to verify, return first match
|
||||||
|
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
|
return isrcMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Tracks.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for ISRC: %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) {
|
||||||
|
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||||
|
// Now includes romaji conversion for Japanese text (same as Tidal)
|
||||||
|
// Also includes title verification to prevent wrong song downloads
|
||||||
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies
|
// Try multiple search strategies (same as Tidal/PC version)
|
||||||
queries := []string{}
|
queries := []string{}
|
||||||
|
|
||||||
// Strategy 1: Artist + Track name
|
// Strategy 1: Artist + Track name
|
||||||
@@ -129,8 +479,54 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Romaji versions if Japanese detected
|
||||||
|
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||||
|
// Convert to romaji (hiragana/katakana only, kanji stays)
|
||||||
|
romajiTrack := JapaneseToRomaji(trackName)
|
||||||
|
romajiArtist := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
|
// Clean and remove ALL non-ASCII characters (including kanji)
|
||||||
|
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||||
|
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||||
|
|
||||||
|
// Artist + Track romaji (cleaned to ASCII only)
|
||||||
|
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||||
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
|
if !containsQueryQobuz(queries, romajiQuery) {
|
||||||
|
queries = append(queries, romajiQuery)
|
||||||
|
GoLog("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track romaji only (cleaned)
|
||||||
|
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||||
|
if !containsQueryQobuz(queries, cleanRomajiTrack) {
|
||||||
|
queries = append(queries, cleanRomajiTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: Artist only as last resort
|
||||||
|
if artistName != "" {
|
||||||
|
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||||
|
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
|
||||||
|
queries = append(queries, artistOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allTracks []QobuzTrack
|
||||||
|
searchedQueries := make(map[string]bool)
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
cleanQuery := strings.TrimSpace(query)
|
||||||
|
if cleanQuery == "" || searchedQueries[cleanQuery] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
searchedQueries[cleanQuery] = true
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,6 +535,7 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,19 +556,82 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
// Return first result with best quality
|
GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
|
||||||
for i := range result.Tracks.Items {
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
track := &result.Tracks.Items[i]
|
|
||||||
if track.MaximumBitDepth >= 24 {
|
|
||||||
return track, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Return first result if no hi-res found
|
|
||||||
return &result.Tracks.Items[0], nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
if len(allTracks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by title match first (NEW - like Tidal)
|
||||||
|
var titleMatches []*QobuzTrack
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
if qobuzTitlesMatch(trackName, track.Title) {
|
||||||
|
titleMatches = append(titleMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
||||||
|
|
||||||
|
// If no title matches, log warning but continue with all tracks
|
||||||
|
tracksToCheck := titleMatches
|
||||||
|
if len(titleMatches) == 0 {
|
||||||
|
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
||||||
|
for i := range allTracks {
|
||||||
|
tracksToCheck = append(tracksToCheck, &allTracks[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If duration verification is requested
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationMatches []*QobuzTrack
|
||||||
|
for _, track := range tracksToCheck {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
if durationDiff <= 10 {
|
||||||
|
durationMatches = append(durationMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationMatches) > 0 {
|
||||||
|
// Return best quality among duration matches
|
||||||
|
for _, track := range durationMatches {
|
||||||
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||||
|
track.Title, track.Performer.Name)
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
||||||
|
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration verification, return best quality from title matches
|
||||||
|
for _, track := range tracksToCheck {
|
||||||
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||||
|
track.Title, track.Performer.Name)
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tracksToCheck) > 0 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
||||||
|
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
||||||
|
return tracksToCheck[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||||
@@ -190,7 +650,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
|
|||||||
// The apiURL already includes the path, just append trackID and quality
|
// The apiURL already includes the path, just append trackID and quality
|
||||||
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
||||||
|
|
||||||
fmt.Printf("[Qobuz] Trying: %s\n", reqURL)
|
GoLog("[Qobuz] Trying: %s\n", reqURL)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", reqURL, nil)
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -235,7 +695,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result.URL != "" {
|
if result.URL != "" {
|
||||||
fmt.Printf("[Qobuz] Got download URL from: %s\n", apiURL)
|
GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
|
||||||
return apiURL, result.URL, nil
|
return apiURL, result.URL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +721,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
if itemID != "" {
|
||||||
SetDownloading(true)
|
StartItemProgress(itemID)
|
||||||
defer SetDownloading(false)
|
defer CompleteItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -282,51 +743,140 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use ProgressWriter for tracking
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
progressWriter := NewProgressWriter(out)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
|
||||||
return err
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
|
if itemID != "" {
|
||||||
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
|
} else {
|
||||||
|
// Fallback: direct copy without progress tracking
|
||||||
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush buffer before checking for errors
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size if Content-Length was provided
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QobuzDownloadResult contains download result with quality info
|
||||||
|
type QobuzDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromQobuz downloads a track using the request parameters
|
// downloadFromQobuz downloads a track using the request parameters
|
||||||
func downloadFromQobuz(req DownloadRequest) (string, error) {
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC
|
// OPTIMIZATION: Check cache first for track ID
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
|
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||||
|
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||||
|
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
|
if track == nil && req.ISRC != "" {
|
||||||
|
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||||
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
|
// Verify artist AND title
|
||||||
|
if track != nil {
|
||||||
|
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
} else if !qobuzTitlesMatch(req.TrackName, track.Title) {
|
||||||
|
GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.TrackName, track.Title)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Search by metadata with duration verification (includes title verification)
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
|
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
errMsg := "could not find track on Qobuz"
|
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log match found and cache the track ID
|
||||||
|
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
|
if req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
@@ -343,69 +893,120 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// 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 "EXISTS:" + outputPath, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map quality from Tidal format to Qobuz format
|
||||||
|
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
|
||||||
|
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
|
||||||
|
qobuzQuality := "27" // Default to highest quality
|
||||||
|
switch req.Quality {
|
||||||
|
case "LOSSLESS":
|
||||||
|
qobuzQuality = "6" // 16-bit FLAC
|
||||||
|
case "HI_RES":
|
||||||
|
qobuzQuality = "7" // 24-bit 96kHz
|
||||||
|
case "HI_RES_LOSSLESS":
|
||||||
|
qobuzQuality = "27" // 24-bit 192kHz
|
||||||
|
}
|
||||||
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
|
// Get actual quality from track metadata
|
||||||
|
actualBitDepth := track.MaximumBitDepth
|
||||||
|
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||||
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
var parallelResult *ParallelDownloadResult
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for parallel operations to complete
|
||||||
|
<-parallelDone
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if req.AlbumName != "" {
|
||||||
|
albumName = req.AlbumName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata
|
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: track.Title,
|
||||||
Artist: req.ArtistName,
|
Artist: track.Performer.Name,
|
||||||
Album: req.AlbumName,
|
Album: albumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||||
Date: req.ReleaseDate,
|
Date: track.Album.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: track.TrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||||
ISRC: req.ISRC,
|
ISRC: track.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download cover to memory (avoids file permission issues on Android)
|
// Use cover data from parallel fetch
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if req.CoverURL != "" {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
fmt.Println("[Qobuz] Downloading cover to memory...")
|
coverData = parallelResult.CoverData
|
||||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
if err == nil {
|
|
||||||
coverData = data
|
|
||||||
fmt.Printf("[Qobuz] Cover downloaded successfully (%d bytes)\n", len(coverData))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Qobuz] Warning: failed to download cover: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed lyrics if enabled
|
// Embed lyrics from parallel fetch
|
||||||
if req.EmbedLyrics {
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
fmt.Println("[Qobuz] Fetching lyrics...")
|
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||||
lyricsClient := NewLyricsClient()
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
if lyricsErr != nil {
|
|
||||||
fmt.Printf("[Qobuz] Warning: lyrics fetch error: %v\n", lyricsErr)
|
|
||||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
fmt.Println("[Qobuz] No lyrics found for this track")
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||||
lrcContent := convertToLRC(lyrics)
|
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
|
||||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputPath, nil
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
|
return QobuzDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: actualBitDepth,
|
||||||
|
SampleRate: actualSampleRate,
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: track.Performer.Name,
|
||||||
|
Album: track.Album.Title,
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,109 +5,61 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Japanese character ranges
|
// Hiragana to Romaji mapping
|
||||||
const (
|
|
||||||
hiraganaStart = 0x3040
|
|
||||||
hiraganaEnd = 0x309F
|
|
||||||
katakanaStart = 0x30A0
|
|
||||||
katakanaEnd = 0x30FF
|
|
||||||
kanjiStart = 0x4E00
|
|
||||||
kanjiEnd = 0x9FFF
|
|
||||||
)
|
|
||||||
|
|
||||||
// hiraganaToRomaji maps hiragana characters to romaji
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
var hiraganaToRomaji = map[rune]string{
|
||||||
// Basic vowels
|
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
// K-row
|
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
// S-row
|
|
||||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||||
// T-row
|
|
||||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||||
// N-row
|
|
||||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||||
// H-row
|
|
||||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||||
// M-row
|
|
||||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||||
// Y-row
|
|
||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
// R-row
|
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
// W-row
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
'わ': "wa", 'を': "wo",
|
// Dakuten (voiced)
|
||||||
// N
|
|
||||||
'ん': "n",
|
|
||||||
// Voiced (dakuten) - G-row
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
// Z-row
|
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
// D-row
|
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
// B-row
|
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
// P-row (handakuten)
|
// Handakuten (semi-voiced)
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
// Small characters
|
// Small characters
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
|
'っ': "", // Double consonant marker
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
'っ': "", // Small tsu - handled specially
|
|
||||||
// Long vowel mark
|
|
||||||
'ー': "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// katakanaToRomaji maps katakana characters to romaji
|
// Katakana to Romaji mapping
|
||||||
var katakanaToRomaji = map[rune]string{
|
var katakanaToRomaji = map[rune]string{
|
||||||
// Basic vowels
|
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
// K-row
|
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
// S-row
|
|
||||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||||
// T-row
|
|
||||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||||
// N-row
|
|
||||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||||
// H-row
|
|
||||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||||
// M-row
|
|
||||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||||
// Y-row
|
|
||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
// R-row
|
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
// W-row
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
'ワ': "wa", 'ヲ': "wo",
|
// Dakuten (voiced)
|
||||||
// N
|
|
||||||
'ン': "n",
|
|
||||||
// Voiced (dakuten) - G-row
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
// Z-row
|
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
// D-row
|
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
// B-row
|
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
// P-row (handakuten)
|
// Handakuten (semi-voiced)
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
// Small characters
|
// Small characters
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
|
'ッ': "", // Double consonant marker
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
'ッ': "", // Small tsu - handled specially
|
|
||||||
// Extended katakana
|
// Extended katakana
|
||||||
|
'ー': "", // Long vowel mark
|
||||||
'ヴ': "vu",
|
'ヴ': "vu",
|
||||||
// Long vowel mark
|
|
||||||
'ー': "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended katakana combinations (multi-character)
|
// Combination mappings for きゃ, しゃ, etc.
|
||||||
var katakanaExtended = map[string]string{
|
var combinationHiragana = map[string]string{
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combination mappings for small ya/yu/yo
|
|
||||||
var hiraganaCombo = map[string]string{
|
|
||||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||||
@@ -121,7 +73,7 @@ var hiraganaCombo = map[string]string{
|
|||||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||||
}
|
}
|
||||||
|
|
||||||
var katakanaCombo = map[string]string{
|
var combinationKatakana = map[string]string{
|
||||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||||
@@ -133,15 +85,13 @@ var katakanaCombo = map[string]string{
|
|||||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
// Extended katakana combinations
|
// Extended combinations
|
||||||
"ティ": "ti", "ディ": "di",
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
"トゥ": "tu", "ドゥ": "du",
|
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
"ヴァ": "va", "ヴィ": "vi", "ヴェ": "ve", "ヴォ": "vo",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainsJapanese checks if a string contains Japanese characters (Hiragana, Katakana, or Kanji)
|
// 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) {
|
||||||
@@ -151,107 +101,72 @@ func ContainsJapanese(s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainsKana checks if a string contains Hiragana or Katakana (convertible to romaji)
|
|
||||||
func ContainsKana(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if isHiragana(r) || isKatakana(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHiragana(r rune) bool {
|
func isHiragana(r rune) bool {
|
||||||
return r >= hiraganaStart && r <= hiraganaEnd
|
return r >= 0x3040 && r <= 0x309F
|
||||||
}
|
}
|
||||||
|
|
||||||
func isKatakana(r rune) bool {
|
func isKatakana(r rune) bool {
|
||||||
return r >= katakanaStart && r <= katakanaEnd
|
return r >= 0x30A0 && r <= 0x30FF
|
||||||
}
|
}
|
||||||
|
|
||||||
func isKanji(r rune) bool {
|
func isKanji(r rune) bool {
|
||||||
return r >= kanjiStart && r <= kanjiEnd
|
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
|
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||||
// Kanji characters are preserved as-is since they require dictionary lookup
|
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||||
func ToRomaji(s string) string {
|
func JapaneseToRomaji(text string) string {
|
||||||
if !ContainsKana(s) {
|
if !ContainsJapanese(text) {
|
||||||
return s
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
runes := []rune(s)
|
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
result.Grow(len(s) * 2) // Romaji is typically longer
|
runes := []rune(text)
|
||||||
|
|
||||||
i := 0
|
i := 0
|
||||||
|
|
||||||
for i < len(runes) {
|
for i < len(runes) {
|
||||||
r := runes[i]
|
// Check for っ/ッ (double consonant)
|
||||||
|
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||||
|
nextRomaji := ""
|
||||||
|
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
}
|
||||||
|
if len(nextRomaji) > 0 {
|
||||||
|
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Check for two-character combinations first
|
// Check for two-character combinations
|
||||||
if i+1 < len(runes) {
|
if i < len(runes)-1 {
|
||||||
combo := string(runes[i : i+2])
|
combo := string(runes[i : i+2])
|
||||||
if romaji, ok := hiraganaCombo[combo]; ok {
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
i += 2
|
i += 2
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if romaji, ok := katakanaCombo[combo]; ok {
|
if romaji, ok := combinationKatakana[combo]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
i += 2
|
i += 2
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle small tsu (っ/ッ) - doubles the next consonant
|
|
||||||
if r == 'っ' || r == 'ッ' {
|
|
||||||
if i+1 < len(runes) {
|
|
||||||
nextRune := runes[i+1]
|
|
||||||
var nextRomaji string
|
|
||||||
if romaji, ok := hiraganaToRomaji[nextRune]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
} else if romaji, ok := katakanaToRomaji[nextRune]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
}
|
|
||||||
if len(nextRomaji) > 0 {
|
|
||||||
result.WriteByte(nextRomaji[0]) // Double the consonant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle long vowel mark (ー)
|
|
||||||
if r == 'ー' {
|
|
||||||
// Extend the previous vowel
|
|
||||||
resultStr := result.String()
|
|
||||||
if len(resultStr) > 0 {
|
|
||||||
lastChar := resultStr[len(resultStr)-1]
|
|
||||||
if lastChar == 'a' || lastChar == 'i' || lastChar == 'u' || lastChar == 'e' || lastChar == 'o' {
|
|
||||||
result.WriteByte(lastChar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single character conversion
|
// Single character conversion
|
||||||
|
r := runes[i]
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
i++
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if romaji, ok := katakanaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
i++
|
} else if isKanji(r) {
|
||||||
continue
|
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||||
}
|
result.WriteRune(r)
|
||||||
|
|
||||||
// Keep non-Japanese characters as-is
|
|
||||||
if unicode.IsSpace(r) {
|
|
||||||
result.WriteRune(' ')
|
|
||||||
} else {
|
} else {
|
||||||
|
// Keep other characters (punctuation, spaces, etc.)
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
@@ -260,17 +175,48 @@ func ToRomaji(s string) string {
|
|||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRomajiVariants returns search variants for Japanese text
|
// BuildSearchQuery creates a search query from track name and artist
|
||||||
// Returns the original string plus romaji version if applicable
|
// Converts Japanese to romaji if present
|
||||||
func GetRomajiVariants(s string) []string {
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
variants := []string{s}
|
// Convert Japanese to romaji
|
||||||
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
if ContainsKana(s) {
|
// Clean up the query - remove special characters that might interfere with search
|
||||||
romaji := ToRomaji(s)
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
if romaji != s && strings.TrimSpace(romaji) != "" {
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
variants = append(variants, romaji)
|
|
||||||
|
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanSearchQuery removes special characters that might interfere with search
|
||||||
|
func cleanSearchQuery(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return strings.TrimSpace(result.String())
|
||||||
return variants
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == ',' || r == '.' {
|
||||||
|
// Convert punctuation to space
|
||||||
|
result.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean up multiple spaces
|
||||||
|
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,20 +22,37 @@ type TrackAvailability struct {
|
|||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
Amazon bool `json:"amazon"`
|
Amazon bool `json:"amazon"`
|
||||||
Qobuz bool `json:"qobuz"`
|
Qobuz bool `json:"qobuz"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
TidalURL string `json:"tidal_url,omitempty"`
|
TidalURL string `json:"tidal_url,omitempty"`
|
||||||
AmazonURL string `json:"amazon_url,omitempty"`
|
AmazonURL string `json:"amazon_url,omitempty"`
|
||||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSongLinkClient creates a new SongLink client
|
var (
|
||||||
|
// Global SongLink client instance for connection reuse
|
||||||
|
globalSongLinkClient *SongLinkClient
|
||||||
|
songLinkClientOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
return &SongLinkClient{
|
songLinkClientOnce.Do(func() {
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
globalSongLinkClient = &SongLinkClient{
|
||||||
}
|
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalSongLinkClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckTrackAvailability checks track availability on streaming platforms
|
// CheckTrackAvailability checks track availability on streaming platforms
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
|
// Validate Spotify ID format (should be 22 characters alphanumeric)
|
||||||
|
if spotifyTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("spotify track ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
// Use global rate limiter - blocks until request is allowed
|
// Use global rate limiter - blocks until request is allowed
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
@@ -57,8 +76,18 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
body, err := ReadResponseBody(resp)
|
||||||
@@ -92,7 +121,15 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
}
|
}
|
||||||
@@ -151,3 +188,357 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
|
|
||||||
return searchResp.Tracks.Total > 0
|
return searchResp.Tracks.Total > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||||
|
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, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
// Get the last part which should be the ID
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
// Remove any query parameters
|
||||||
|
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
||||||
|
lastPart = lastPart[:idx]
|
||||||
|
}
|
||||||
|
return lastPart
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Deezer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.DeezerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumAvailability represents album availability on different platforms
|
||||||
|
type AlbumAvailability struct {
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
||||||
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build API URL for album
|
||||||
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||||
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||||
|
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &AlbumAvailability{
|
||||||
|
SpotifyID: spotifyAlbumID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
|
||||||
|
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
|
return "", fmt.Errorf("album not found on Deezer")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
if deezerTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build Deezer URL
|
||||||
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
|
// Build API URL using Deezer URL as source
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
EntitiesByUniqueId map[string]struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
} `json:"entitiesByUniqueId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
Deezer: true,
|
||||||
|
DeezerID: deezerTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
// Extract Spotify ID from URL
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer URL
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
||||||
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||||
|
// entityType: "song" or "album"
|
||||||
|
// entityID: the ID on that platform
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entityID string) (*TrackAvailability, error) {
|
||||||
|
if entityID == "" {
|
||||||
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build API URL using platform, type, and id parameters (as per API docs)
|
||||||
|
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
||||||
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||||
|
url.QueryEscape(platform),
|
||||||
|
url.QueryEscape(entityType),
|
||||||
|
url.QueryEscape(entityID))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||||
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
|
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
|
||||||
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
// Get the ID part and remove any query parameters
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Spotify")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.SpotifyID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Tidal || availability.TidalURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Tidal")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.TidalURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Amazon Music")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.AmazonURL, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,11 +21,28 @@ const (
|
|||||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||||
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
|
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
|
||||||
|
searchCacheTTL = 5 * time.Minute
|
||||||
|
albumCacheTTL = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
||||||
|
data interface{}
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cacheEntry) isExpired() bool {
|
||||||
|
return time.Now().After(e.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
// SpotifyMetadataClient handles Spotify API interactions
|
// SpotifyMetadataClient handles Spotify API interactions
|
||||||
type SpotifyMetadataClient struct {
|
type SpotifyMetadataClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -32,31 +50,76 @@ type SpotifyMetadataClient struct {
|
|||||||
clientSecret string
|
clientSecret string
|
||||||
cachedToken string
|
cachedToken string
|
||||||
tokenExpiresAt time.Time
|
tokenExpiresAt time.Time
|
||||||
|
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||||
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 // key: artistID
|
||||||
|
searchCache map[string]*cacheEntry // key: query+type
|
||||||
|
albumCache map[string]*cacheEntry // key: albumID
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom credentials storage (set from Flutter)
|
||||||
|
var (
|
||||||
|
customClientID string
|
||||||
|
customClientSecret string
|
||||||
|
credentialsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||||
|
// Pass empty strings to use default credentials
|
||||||
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
|
credentialsMu.Lock()
|
||||||
|
defer credentialsMu.Unlock()
|
||||||
|
customClientID = clientID
|
||||||
|
customClientSecret = clientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCredentials returns the current credentials (custom or default)
|
||||||
|
func getCredentials() (string, string) {
|
||||||
|
credentialsMu.RLock()
|
||||||
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
if customClientID != "" && customClientSecret != "" {
|
||||||
|
return customClientID, customClientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default credentials
|
||||||
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
|
if clientID == "" {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||||
|
clientID = string(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
if clientSecret == "" {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||||
|
clientSecret = string(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientID, clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
// Decode credentials from base64
|
// Get credentials (custom or default)
|
||||||
clientID := ""
|
clientID, clientSecret := getCredentials()
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
|
||||||
clientID = string(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientSecret := ""
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
|
||||||
clientSecret = string(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
searchCache: make(map[string]*cacheEntry),
|
||||||
|
albumCache: make(map[string]*cacheEntry),
|
||||||
}
|
}
|
||||||
c.userAgent = c.randomUserAgent()
|
c.userAgent = c.randomUserAgent()
|
||||||
return c
|
return c
|
||||||
@@ -131,6 +194,32 @@ type PlaylistResponsePayload struct {
|
|||||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArtistInfoMetadata holds artist information
|
||||||
|
type ArtistInfoMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistAlbumMetadata holds album info for artist discography
|
||||||
|
type ArtistAlbumMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
AlbumType string `json:"album_type"` // album, single, compilation
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistResponsePayload is the response for artist requests
|
||||||
|
type ArtistResponsePayload struct {
|
||||||
|
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||||
|
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||||
|
}
|
||||||
|
|
||||||
// TrackResponse is the response for single track requests
|
// TrackResponse is the response for single track requests
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
Track TrackMetadata `json:"track"`
|
Track TrackMetadata `json:"track"`
|
||||||
@@ -142,6 +231,21 @@ type SearchResult struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchArtistResult represents an artist in search results
|
||||||
|
type SearchArtistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAllResult represents combined search results for tracks and artists
|
||||||
|
type SearchAllResult struct {
|
||||||
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
|
Artists []SearchArtistResult `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
type spotifyURI struct {
|
type spotifyURI struct {
|
||||||
Type string
|
Type string
|
||||||
ID string
|
ID string
|
||||||
@@ -212,6 +316,8 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
|||||||
return c.fetchAlbum(ctx, parsed.ID, token)
|
return c.fetchAlbum(ctx, parsed.ID, token)
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return c.fetchPlaylist(ctx, parsed.ID, token)
|
return c.fetchPlaylist(ctx, parsed.ID, token)
|
||||||
|
case "artist":
|
||||||
|
return c.fetchArtist(ctx, parsed.ID, token)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
}
|
}
|
||||||
@@ -263,6 +369,98 @@ 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) {
|
||||||
|
// Create cache key
|
||||||
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*SearchAllResult), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
token, err := c.getAccessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []trackFull `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
Artists struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
Followers struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &SearchAllResult{
|
||||||
|
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
|
||||||
|
Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, track := range response.Tracks.Items {
|
||||||
|
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||||
|
SpotifyID: track.ID,
|
||||||
|
Artists: joinArtists(track.Artists),
|
||||||
|
Name: track.Name,
|
||||||
|
AlbumName: track.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(track.Album.Artists),
|
||||||
|
DurationMS: track.DurationMS,
|
||||||
|
Images: firstImageURL(track.Album.Images),
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
TotalTracks: track.Album.TotalTracks,
|
||||||
|
DiscNumber: track.DiscNumber,
|
||||||
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit artists to artistLimit
|
||||||
|
artistCount := len(response.Artists.Items)
|
||||||
|
if artistCount > artistLimit {
|
||||||
|
artistCount = artistLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < artistCount; i++ {
|
||||||
|
artist := response.Artists.Items[i]
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: artist.ID,
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: firstImageURL(artist.Images),
|
||||||
|
Followers: artist.Followers.Total,
|
||||||
|
Popularity: artist.Popularity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(searchCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
||||||
var data trackFull
|
var data trackFull
|
||||||
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
||||||
@@ -289,6 +487,25 @@ 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()
|
||||||
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Track item structure for pagination
|
||||||
|
type trackItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
|
Artists []artist `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
@@ -296,15 +513,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Items []struct {
|
Items []trackItem `json:"items"`
|
||||||
ID string `json:"id"`
|
Next string `json:"next"`
|
||||||
Name string `json:"name"`
|
|
||||||
DurationMS int `json:"duration_ms"`
|
|
||||||
TrackNumber int `json:"track_number"`
|
|
||||||
DiscNumber int `json:"disc_number"`
|
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
|
||||||
Artists []artist `json:"artists"`
|
|
||||||
} `json:"items"`
|
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,10 +531,38 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
// Collect all tracks (including paginated)
|
||||||
for _, item := range data.Tracks.Items {
|
allTrackItems := data.Tracks.Items
|
||||||
// Fetch ISRC for each track
|
nextURL := data.Tracks.Next
|
||||||
isrc := c.fetchTrackISRC(ctx, item.ID, token)
|
|
||||||
|
// Fetch remaining tracks using pagination (no limit)
|
||||||
|
for nextURL != "" {
|
||||||
|
var pageData struct {
|
||||||
|
Items []trackItem `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
|
fmt.Printf("[Spotify] Warning: failed to fetch album tracks page: %v\n", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
allTrackItems = append(allTrackItems, pageData.Items...)
|
||||||
|
nextURL = pageData.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||||
|
|
||||||
|
// Collect track IDs for parallel ISRC fetching
|
||||||
|
trackIDs := make([]string, len(allTrackItems))
|
||||||
|
for i, item := range allTrackItems {
|
||||||
|
trackIDs[i] = item.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||||
|
for _, item := range allTrackItems {
|
||||||
|
isrc := isrcMap[item.ID]
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.ID,
|
SpotifyID: item.ID,
|
||||||
@@ -344,13 +582,65 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
result := &AlbumResponsePayload{
|
||||||
AlbumInfo: info,
|
AlbumInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(albumCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||||
|
|
||||||
|
result := make(map[string]string)
|
||||||
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
|
if len(trackIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use semaphore to limit concurrent requests
|
||||||
|
sem := make(chan struct{}, maxParallelISRC)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, trackID := range trackIDs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Acquire semaphore
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||||
|
|
||||||
|
resultMu.Lock()
|
||||||
|
result[id] = isrc
|
||||||
|
resultMu.Unlock()
|
||||||
|
}(trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
|
// First request to get playlist info and first batch of tracks
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
@@ -361,7 +651,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
Items []struct {
|
Items []struct {
|
||||||
Track *trackFull `json:"track"`
|
Track *trackFull `json:"track"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
|
Next string `json:"next"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +666,10 @@ 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)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
// Pre-allocate with expected capacity
|
||||||
|
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
|
||||||
@@ -399,12 +693,157 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||||
|
nextURL := data.Tracks.Next
|
||||||
|
|
||||||
|
for nextURL != "" {
|
||||||
|
var pageData struct {
|
||||||
|
Items []struct {
|
||||||
|
Track *trackFull `json:"track"`
|
||||||
|
} `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range pageData.Items {
|
||||||
|
if item.Track == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: item.Track.ID,
|
||||||
|
Artists: joinArtists(item.Track.Artists),
|
||||||
|
Name: item.Track.Name,
|
||||||
|
AlbumName: item.Track.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(item.Track.Album.Artists),
|
||||||
|
DurationMS: item.Track.DurationMS,
|
||||||
|
Images: firstImageURL(item.Track.Album.Images),
|
||||||
|
ReleaseDate: item.Track.Album.ReleaseDate,
|
||||||
|
TrackNumber: item.Track.TrackNumber,
|
||||||
|
TotalTracks: item.Track.Album.TotalTracks,
|
||||||
|
DiscNumber: item.Track.DiscNumber,
|
||||||
|
ExternalURL: item.Track.ExternalURL.Spotify,
|
||||||
|
ISRC: item.Track.ExternalID.ISRC,
|
||||||
|
AlbumID: item.Track.Album.ID,
|
||||||
|
AlbumURL: item.Track.Album.ExternalURL.Spotify,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nextURL = pageData.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
|
||||||
|
|
||||||
return &PlaylistResponsePayload{
|
return &PlaylistResponsePayload{
|
||||||
PlaylistInfo: info,
|
PlaylistInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*ArtistResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Fetch artist info
|
||||||
|
var artistData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
Followers struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artistInfo := ArtistInfoMetadata{
|
||||||
|
ID: artistData.ID,
|
||||||
|
Name: artistData.Name,
|
||||||
|
Images: firstImageURL(artistData.Images),
|
||||||
|
Followers: artistData.Followers.Total,
|
||||||
|
Popularity: artistData.Popularity,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch artist albums (all types: album, single, compilation)
|
||||||
|
albums := make([]ArtistAlbumMetadata, 0)
|
||||||
|
offset := 0
|
||||||
|
limit := 50
|
||||||
|
|
||||||
|
for {
|
||||||
|
albumsURL := fmt.Sprintf("%s?include_groups=album,single,compilation&limit=%d&offset=%d",
|
||||||
|
fmt.Sprintf(artistAlbumsURL, artistID), limit, offset)
|
||||||
|
|
||||||
|
var albumsData struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
|
Artists []artist `json:"artists"`
|
||||||
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
|
} `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, albumsURL, token, &albumsData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, album := range albumsData.Items {
|
||||||
|
albums = append(albums, ArtistAlbumMetadata{
|
||||||
|
ID: album.ID,
|
||||||
|
Name: album.Name,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TotalTracks: album.TotalTracks,
|
||||||
|
Images: firstImageURL(album.Images),
|
||||||
|
AlbumType: album.AlbumType,
|
||||||
|
Artists: joinArtists(album.Artists),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are more albums
|
||||||
|
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += limit
|
||||||
|
|
||||||
|
// Safety limit to prevent infinite loops
|
||||||
|
if offset > 500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ArtistResponsePayload{
|
||||||
|
ArtistInfo: artistInfo,
|
||||||
|
Albums: albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(artistCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
||||||
var data struct {
|
var data struct {
|
||||||
ExternalID externalID `json:"external_ids"`
|
ExternalID externalID `json:"external_ids"`
|
||||||
@@ -465,8 +904,16 @@ 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("sec-ch-ua-platform", "\"Windows\"")
|
||||||
|
req.Header.Set("sec-fetch-dest", "empty")
|
||||||
|
req.Header.Set("sec-fetch-mode", "cors")
|
||||||
|
req.Header.Set("sec-fetch-site", "same-origin")
|
||||||
|
req.Header.Set("Referer", "https://open.spotify.com/")
|
||||||
|
req.Header.Set("Origin", "https://open.spotify.com")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
@@ -493,13 +940,23 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
c.rngMu.Lock()
|
c.rngMu.Lock()
|
||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
chromeMajor := 80 + c.rng.Intn(25)
|
// Use Mac User-Agent format (same as PC version)
|
||||||
chromeBuild := 3000 + c.rng.Intn(1500)
|
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||||
chromePatch := 60 + c.rng.Intn(65)
|
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||||
|
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
|
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||||
|
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||||
|
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||||
|
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||||
|
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
|
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
"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,
|
chromeMajor, chromeBuild, chromePatch,
|
||||||
|
safariMajor, safariMinor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -427,7 +427,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "searchSpotifyAll":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||||
|
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||||
|
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -90,10 +99,33 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendGetDownloadProgress()
|
let response = GobackendGetDownloadProgress()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "getAllDownloadProgress":
|
||||||
|
let response = GobackendGetAllDownloadProgress()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "initItemProgress":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendInitItemProgress(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "finishItemProgress":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendFinishItemProgress(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "clearItemProgress":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendClearItemProgress(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "setDownloadDirectory":
|
case "setDownloadDirectory":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let path = args["path"] as! String
|
let path = args["path"] as! String
|
||||||
try GobackendSetDownloadDirectory(path)
|
GobackendSetDownloadDirectory(path, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case "checkDuplicate":
|
case "checkDuplicate":
|
||||||
@@ -132,7 +164,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 = GobackendGetLyricsLRC(spotifyId, trackName, artistName, &error)
|
let filePath = args["file_path"] as? String ?? ""
|
||||||
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -144,6 +177,110 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "cleanupConnections":
|
||||||
|
GobackendCleanupConnections()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "readFileMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let filePath = args["file_path"] as! String
|
||||||
|
let response = GobackendReadFileMetadata(filePath, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "searchDeezerAll":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||||
|
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||||
|
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getDeezerMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let resourceType = args["resource_type"] as! String
|
||||||
|
let resourceId = args["resource_id"] as! String
|
||||||
|
let response = GobackendGetDeezerMetadata(resourceType, resourceId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "parseDeezerUrl":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendParseDeezerURLExport(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "searchDeezerByISRC":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let isrc = args["isrc"] as! String
|
||||||
|
let response = GobackendSearchDeezerByISRC(isrc, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "convertSpotifyToDeezer":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let resourceType = args["resource_type"] as! String
|
||||||
|
let spotifyId = args["spotify_id"] as! String
|
||||||
|
let response = GobackendConvertSpotifyToDeezer(resourceType, spotifyId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getSpotifyMetadataWithFallback":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let url = args["url"] as! String
|
||||||
|
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "preWarmTrackCache":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let tracksJson = args["tracks"] as! String
|
||||||
|
let _ = GobackendPreWarmTrackCacheJSON(tracksJson, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getTrackCacheSize":
|
||||||
|
let response = GobackendGetTrackCacheSize()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearTrackCache":
|
||||||
|
GobackendClearTrackCache()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "setSpotifyCredentials":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let clientId = args["client_id"] as! String
|
||||||
|
let clientSecret = args["client_secret"] as! String
|
||||||
|
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
// Log methods
|
||||||
|
case "getLogs":
|
||||||
|
let response = GobackendGetLogs()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "getLogsSince":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let index = args["index"] as? Int ?? 0
|
||||||
|
let response = GobackendGetLogsSince(Int(index))
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "clearLogs":
|
||||||
|
GobackendClearLogs()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "getLogCount":
|
||||||
|
let response = GobackendGetLogCount()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "setLoggingEnabled":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let enabled = args["enabled"] as? Bool ?? false
|
||||||
|
GobackendSetLoggingEnabled(enabled)
|
||||||
|
return nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -1,122 +1 @@
|
|||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 318 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 744 B |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 419 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 789 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 752 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -7,10 +7,11 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
final settings = ref.watch(settingsProvider);
|
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
||||||
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: settings.isFirstLaunch ? '/setup' : '/',
|
initialLocation: isFirstLaunch ? '/setup' : '/',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/// App version and info constants
|
||||||
|
/// Update version here only - all other files will reference this
|
||||||
|
class AppInfo {
|
||||||
|
static const String version = '2.2.7';
|
||||||
|
static const String buildNumber = '49';
|
||||||
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
static const String appName = 'SpotiFLAC';
|
||||||
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
|
static const String mobileAuthor = 'zarzet';
|
||||||
|
static const String originalAuthor = 'afkarxyz';
|
||||||
|
|
||||||
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
|
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||||
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
|
}
|
||||||
@@ -1,12 +1,37 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/app.dart';
|
import 'package:spotiflac_android/app.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize notification service
|
||||||
|
await NotificationService().initialize();
|
||||||
|
|
||||||
|
// Initialize share intent service
|
||||||
|
await ShareIntentService().initialize();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: SpotiFLACApp(),
|
child: const _EagerInitialization(
|
||||||
|
child: SpotiFLACApp(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Widget to eagerly initialize providers that need to load data on startup
|
||||||
|
class _EagerInitialization extends ConsumerWidget {
|
||||||
|
const _EagerInitialization({required this.child});
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Eagerly initialize download history provider to load from storage
|
||||||
|
ref.watch(downloadHistoryProvider);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ part 'download_item.g.dart';
|
|||||||
enum DownloadStatus {
|
enum DownloadStatus {
|
||||||
queued,
|
queued,
|
||||||
downloading,
|
downloading,
|
||||||
|
finalizing, // Embedding metadata, cover, lyrics
|
||||||
completed,
|
completed,
|
||||||
failed,
|
failed,
|
||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error type enum for better error handling
|
||||||
|
enum DownloadErrorType {
|
||||||
|
unknown,
|
||||||
|
notFound, // Track not found on any service
|
||||||
|
rateLimit, // Rate limited by service
|
||||||
|
network, // Network/connection error
|
||||||
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class DownloadItem {
|
class DownloadItem {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -19,9 +28,12 @@ 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 String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final DownloadErrorType? errorType;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
|
||||||
const DownloadItem({
|
const DownloadItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -29,9 +41,12 @@ class DownloadItem {
|
|||||||
required this.service,
|
required this.service,
|
||||||
this.status = DownloadStatus.queued,
|
this.status = DownloadStatus.queued,
|
||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
|
this.speedMBps = 0.0,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.errorType,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.qualityOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
DownloadItem copyWith({
|
DownloadItem copyWith({
|
||||||
@@ -40,9 +55,12 @@ class DownloadItem {
|
|||||||
String? service,
|
String? service,
|
||||||
DownloadStatus? status,
|
DownloadStatus? status,
|
||||||
double? progress,
|
double? progress,
|
||||||
|
double? speedMBps,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
|
DownloadErrorType? errorType,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
String? qualityOverride,
|
||||||
}) {
|
}) {
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -50,12 +68,31 @@ class DownloadItem {
|
|||||||
service: service ?? this.service,
|
service: service ?? this.service,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
progress: progress ?? this.progress,
|
progress: progress ?? this.progress,
|
||||||
|
speedMBps: speedMBps ?? this.speedMBps,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
|
errorType: errorType ?? this.errorType,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get user-friendly error message based on error type
|
||||||
|
String get errorMessage {
|
||||||
|
if (error == null) return '';
|
||||||
|
|
||||||
|
switch (errorType) {
|
||||||
|
case DownloadErrorType.notFound:
|
||||||
|
return 'Song not found on any service';
|
||||||
|
case DownloadErrorType.rateLimit:
|
||||||
|
return 'Rate limit reached, try again later';
|
||||||
|
case DownloadErrorType.network:
|
||||||
|
return 'Connection failed, check your internet';
|
||||||
|
default:
|
||||||
|
return error ?? 'An error occurred';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
||||||
_$DownloadItemFromJson(json);
|
_$DownloadItemFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
||||||
|
|||||||
@@ -7,52 +7,48 @@ part of 'download_item.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
track: Track.fromJson(json['track'] as Map<String, dynamic>),
|
track: Track.fromJson(json['track'] as Map<String, dynamic>),
|
||||||
service: json['service'] as String,
|
service: json['service'] as String,
|
||||||
status: $enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
status:
|
||||||
DownloadStatus.queued,
|
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
DownloadStatus.queued,
|
||||||
filePath: json['filePath'] as String?,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
error: json['error'] as String?,
|
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
filePath: json['filePath'] as String?,
|
||||||
);
|
error: json['error'] as String?,
|
||||||
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'track': instance.track.toJson(),
|
'track': instance.track,
|
||||||
'service': instance.service,
|
'service': instance.service,
|
||||||
'status': _$DownloadStatusEnumMap[instance.status]!,
|
'status': _$DownloadStatusEnumMap[instance.status]!,
|
||||||
'progress': instance.progress,
|
'progress': instance.progress,
|
||||||
|
'speedMBps': instance.speedMBps,
|
||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
|
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
|
'qualityOverride': instance.qualityOverride,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$DownloadStatusEnumMap = {
|
const _$DownloadStatusEnumMap = {
|
||||||
DownloadStatus.queued: 'queued',
|
DownloadStatus.queued: 'queued',
|
||||||
DownloadStatus.downloading: 'downloading',
|
DownloadStatus.downloading: 'downloading',
|
||||||
|
DownloadStatus.finalizing: 'finalizing',
|
||||||
DownloadStatus.completed: 'completed',
|
DownloadStatus.completed: 'completed',
|
||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
DownloadStatus.skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
K? $enumDecodeNullable<K, V>(
|
const _$DownloadErrorTypeEnumMap = {
|
||||||
Map<K, V> enumValues,
|
DownloadErrorType.unknown: 'unknown',
|
||||||
Object? source, {
|
DownloadErrorType.notFound: 'notFound',
|
||||||
K? unknownValue,
|
DownloadErrorType.rateLimit: 'rateLimit',
|
||||||
}) {
|
DownloadErrorType.network: 'network',
|
||||||
if (source == null) {
|
};
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return enumValues.entries
|
|
||||||
.singleWhere(
|
|
||||||
(e) => e.value == source,
|
|
||||||
orElse: () => throw ArgumentError(
|
|
||||||
'`$source` is not one of the supported values: '
|
|
||||||
'${enumValues.values.join(', ')}',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.key;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ 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 bool checkForUpdates; // Check for updates on app start
|
||||||
|
final String updateChannel; // stable, preview
|
||||||
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
|
final String historyViewMode; // list, grid
|
||||||
|
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||||
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
|
final bool enableLogging; // Enable detailed logging for debugging
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -22,6 +34,18 @@ 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.checkForUpdates = true, // Default: enabled
|
||||||
|
this.updateChannel = 'stable', // Default: stable releases only
|
||||||
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
|
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||||
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
|
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||||
|
this.enableLogging = false, // Default: disabled for performance
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -33,6 +57,18 @@ class AppSettings {
|
|||||||
bool? embedLyrics,
|
bool? embedLyrics,
|
||||||
bool? maxQualityCover,
|
bool? maxQualityCover,
|
||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
|
int? concurrentDownloads,
|
||||||
|
bool? checkForUpdates,
|
||||||
|
String? updateChannel,
|
||||||
|
bool? hasSearchedBefore,
|
||||||
|
String? folderOrganization,
|
||||||
|
String? historyViewMode,
|
||||||
|
bool? askQualityBeforeDownload,
|
||||||
|
String? spotifyClientId,
|
||||||
|
String? spotifyClientSecret,
|
||||||
|
bool? useCustomSpotifyCredentials,
|
||||||
|
String? metadataSource,
|
||||||
|
bool? enableLogging,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -43,6 +79,18 @@ class AppSettings {
|
|||||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||||
|
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||||
|
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||||
|
updateChannel: updateChannel ?? this.updateChannel,
|
||||||
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
|
enableLogging: enableLogging ?? this.enableLogging,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,28 @@ part of 'settings.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||||
defaultService: json['defaultService'] as String? ?? 'tidal',
|
defaultService: json['defaultService'] as String? ?? 'tidal',
|
||||||
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
||||||
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
||||||
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
||||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
);
|
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||||
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
|
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||||
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
|
useCustomSpotifyCredentials:
|
||||||
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
|
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -27,4 +40,16 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'embedLyrics': instance.embedLyrics,
|
'embedLyrics': instance.embedLyrics,
|
||||||
'maxQualityCover': instance.maxQualityCover,
|
'maxQualityCover': instance.maxQualityCover,
|
||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
|
'updateChannel': instance.updateChannel,
|
||||||
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
|
'folderOrganization': instance.folderOrganization,
|
||||||
|
'historyViewMode': instance.historyViewMode,
|
||||||
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
|
'metadataSource': instance.metadataSource,
|
||||||
|
'enableLogging': instance.enableLogging,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
const String kThemeModeKey = 'theme_mode';
|
const String kThemeModeKey = 'theme_mode';
|
||||||
const String kUseDynamicColorKey = 'use_dynamic_color';
|
const String kUseDynamicColorKey = 'use_dynamic_color';
|
||||||
const String kSeedColorKey = 'seed_color';
|
const String kSeedColorKey = 'seed_color';
|
||||||
|
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;
|
||||||
@@ -13,11 +14,13 @@ class ThemeSettings {
|
|||||||
final ThemeMode themeMode;
|
final ThemeMode themeMode;
|
||||||
final bool useDynamicColor;
|
final bool useDynamicColor;
|
||||||
final int seedColorValue;
|
final int seedColorValue;
|
||||||
|
final bool useAmoled; // Pure black background for OLED screens
|
||||||
|
|
||||||
const ThemeSettings({
|
const ThemeSettings({
|
||||||
this.themeMode = ThemeMode.system,
|
this.themeMode = ThemeMode.system,
|
||||||
this.useDynamicColor = true,
|
this.useDynamicColor = true,
|
||||||
this.seedColorValue = kDefaultSeedColor,
|
this.seedColorValue = kDefaultSeedColor,
|
||||||
|
this.useAmoled = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Get seed color as Color object
|
/// Get seed color as Color object
|
||||||
@@ -28,11 +31,13 @@ class ThemeSettings {
|
|||||||
ThemeMode? themeMode,
|
ThemeMode? themeMode,
|
||||||
bool? useDynamicColor,
|
bool? useDynamicColor,
|
||||||
int? seedColorValue,
|
int? seedColorValue,
|
||||||
|
bool? useAmoled,
|
||||||
}) {
|
}) {
|
||||||
return ThemeSettings(
|
return ThemeSettings(
|
||||||
themeMode: themeMode ?? this.themeMode,
|
themeMode: themeMode ?? this.themeMode,
|
||||||
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
||||||
seedColorValue: seedColorValue ?? this.seedColorValue,
|
seedColorValue: seedColorValue ?? this.seedColorValue,
|
||||||
|
useAmoled: useAmoled ?? this.useAmoled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ class ThemeSettings {
|
|||||||
kThemeModeKey: themeMode.name,
|
kThemeModeKey: themeMode.name,
|
||||||
kUseDynamicColorKey: useDynamicColor,
|
kUseDynamicColorKey: useDynamicColor,
|
||||||
kSeedColorKey: seedColorValue,
|
kSeedColorKey: seedColorValue,
|
||||||
|
kUseAmoledKey: useAmoled,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Create from JSON map
|
/// Create from JSON map
|
||||||
@@ -49,6 +55,7 @@ class ThemeSettings {
|
|||||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||||
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
||||||
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
||||||
|
useAmoled: json[kUseAmoledKey] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,12 +65,13 @@ class ThemeSettings {
|
|||||||
return other is ThemeSettings &&
|
return other is ThemeSettings &&
|
||||||
other.themeMode == themeMode &&
|
other.themeMode == themeMode &&
|
||||||
other.useDynamicColor == useDynamicColor &&
|
other.useDynamicColor == useDynamicColor &&
|
||||||
other.seedColorValue == seedColorValue;
|
other.seedColorValue == seedColorValue &&
|
||||||
|
other.useAmoled == useAmoled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode;
|
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to convert string to ThemeMode
|
/// Helper to convert string to ThemeMode
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Track {
|
|||||||
final int? trackNumber;
|
final int? trackNumber;
|
||||||
final int? discNumber;
|
final int? discNumber;
|
||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
|
final String? deezerId;
|
||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
@@ -30,6 +31,7 @@ class Track {
|
|||||||
this.trackNumber,
|
this.trackNumber,
|
||||||
this.discNumber,
|
this.discNumber,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
|
this.deezerId,
|
||||||
this.availability,
|
this.availability,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,17 +44,23 @@ class ServiceAvailability {
|
|||||||
final bool tidal;
|
final bool tidal;
|
||||||
final bool qobuz;
|
final bool qobuz;
|
||||||
final bool amazon;
|
final bool amazon;
|
||||||
|
final bool deezer;
|
||||||
final String? tidalUrl;
|
final String? tidalUrl;
|
||||||
final String? qobuzUrl;
|
final String? qobuzUrl;
|
||||||
final String? amazonUrl;
|
final String? amazonUrl;
|
||||||
|
final String? deezerUrl;
|
||||||
|
final String? deezerId;
|
||||||
|
|
||||||
const ServiceAvailability({
|
const ServiceAvailability({
|
||||||
this.tidal = false,
|
this.tidal = false,
|
||||||
this.qobuz = false,
|
this.qobuz = false,
|
||||||
this.amazon = false,
|
this.amazon = false,
|
||||||
|
this.deezer = false,
|
||||||
this.tidalUrl,
|
this.tidalUrl,
|
||||||
this.qobuzUrl,
|
this.qobuzUrl,
|
||||||
this.amazonUrl,
|
this.amazonUrl,
|
||||||
|
this.deezerUrl,
|
||||||
|
this.deezerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -7,55 +7,64 @@ part of 'track.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
artistName: json['artistName'] as String,
|
artistName: json['artistName'] as String,
|
||||||
albumName: json['albumName'] as String,
|
albumName: json['albumName'] as String,
|
||||||
albumArtist: json['albumArtist'] as String?,
|
albumArtist: json['albumArtist'] as String?,
|
||||||
coverUrl: json['coverUrl'] as String?,
|
coverUrl: json['coverUrl'] as String?,
|
||||||
isrc: json['isrc'] as String?,
|
isrc: json['isrc'] as String?,
|
||||||
duration: (json['duration'] as num).toInt(),
|
duration: (json['duration'] as num).toInt(),
|
||||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
availability: json['availability'] == null
|
deezerId: json['deezerId'] as String?,
|
||||||
? null
|
availability: json['availability'] == null
|
||||||
: ServiceAvailability.fromJson(
|
? null
|
||||||
json['availability'] as Map<String, dynamic>),
|
: ServiceAvailability.fromJson(
|
||||||
);
|
json['availability'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'artistName': instance.artistName,
|
'artistName': instance.artistName,
|
||||||
'albumName': instance.albumName,
|
'albumName': instance.albumName,
|
||||||
'albumArtist': instance.albumArtist,
|
'albumArtist': instance.albumArtist,
|
||||||
'coverUrl': instance.coverUrl,
|
'coverUrl': instance.coverUrl,
|
||||||
'isrc': instance.isrc,
|
'isrc': instance.isrc,
|
||||||
'duration': instance.duration,
|
'duration': instance.duration,
|
||||||
'trackNumber': instance.trackNumber,
|
'trackNumber': instance.trackNumber,
|
||||||
'discNumber': instance.discNumber,
|
'discNumber': instance.discNumber,
|
||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
'availability': instance.availability?.toJson(),
|
'deezerId': instance.deezerId,
|
||||||
};
|
'availability': instance.availability,
|
||||||
|
};
|
||||||
|
|
||||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||||
ServiceAvailability(
|
ServiceAvailability(
|
||||||
tidal: json['tidal'] as bool? ?? false,
|
tidal: json['tidal'] as bool? ?? false,
|
||||||
qobuz: json['qobuz'] as bool? ?? false,
|
qobuz: json['qobuz'] as bool? ?? false,
|
||||||
amazon: json['amazon'] as bool? ?? false,
|
amazon: json['amazon'] as bool? ?? false,
|
||||||
|
deezer: json['deezer'] as bool? ?? false,
|
||||||
tidalUrl: json['tidalUrl'] as String?,
|
tidalUrl: json['tidalUrl'] as String?,
|
||||||
qobuzUrl: json['qobuzUrl'] as String?,
|
qobuzUrl: json['qobuzUrl'] as String?,
|
||||||
amazonUrl: json['amazonUrl'] as String?,
|
amazonUrl: json['amazonUrl'] as String?,
|
||||||
|
deezerUrl: json['deezerUrl'] as String?,
|
||||||
|
deezerId: json['deezerId'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
||||||
ServiceAvailability instance) =>
|
ServiceAvailability instance,
|
||||||
<String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'tidal': instance.tidal,
|
'tidal': instance.tidal,
|
||||||
'qobuz': instance.qobuz,
|
'qobuz': instance.qobuz,
|
||||||
'amazon': instance.amazon,
|
'amazon': instance.amazon,
|
||||||
'tidalUrl': instance.tidalUrl,
|
'deezer': instance.deezer,
|
||||||
'qobuzUrl': instance.qobuzUrl,
|
'tidalUrl': instance.tidalUrl,
|
||||||
'amazonUrl': instance.amazonUrl,
|
'qobuzUrl': instance.qobuzUrl,
|
||||||
};
|
'amazonUrl': instance.amazonUrl,
|
||||||
|
'deezerUrl': instance.deezerUrl,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import 'dart:convert';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
|
const _currentMigrationVersion = 1;
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
@override
|
@override
|
||||||
@@ -17,6 +21,35 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
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);
|
||||||
|
|
||||||
|
// Apply Spotify credentials to Go backend on load
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
|
||||||
|
// Sync logging state
|
||||||
|
LogBuffer.loggingEnabled = state.enableLogging;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one-time migrations for settings
|
||||||
|
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||||
|
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
state = state.copyWith(metadataSource: 'deezer');
|
||||||
|
await _saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current migration version
|
||||||
|
if (lastMigration < _currentMigrationVersion) {
|
||||||
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +58,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
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 {
|
||||||
|
// Only apply custom credentials if enabled and both fields are set
|
||||||
|
if (state.useCustomSpotifyCredentials &&
|
||||||
|
state.spotifyClientId.isNotEmpty &&
|
||||||
|
state.spotifyClientSecret.isNotEmpty) {
|
||||||
|
await PlatformBridge.setSpotifyCredentials(
|
||||||
|
state.spotifyClientId,
|
||||||
|
state.spotifyClientSecret,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Clear to use default
|
||||||
|
await PlatformBridge.setSpotifyCredentials('', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setDefaultService(String service) {
|
void setDefaultService(String service) {
|
||||||
state = state.copyWith(defaultService: service);
|
state = state.copyWith(defaultService: service);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -64,6 +113,91 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(isFirstLaunch: false);
|
state = state.copyWith(isFirstLaunch: false);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setConcurrentDownloads(int count) {
|
||||||
|
// Clamp between 1 and 3
|
||||||
|
final clamped = count.clamp(1, 3);
|
||||||
|
state = state.copyWith(concurrentDownloads: clamped);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCheckForUpdates(bool enabled) {
|
||||||
|
state = state.copyWith(checkForUpdates: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUpdateChannel(String channel) {
|
||||||
|
state = state.copyWith(updateChannel: channel);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setHasSearchedBefore() {
|
||||||
|
if (!state.hasSearchedBefore) {
|
||||||
|
state = state.copyWith(hasSearchedBefore: true);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFolderOrganization(String organization) {
|
||||||
|
state = state.copyWith(folderOrganization: organization);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setHistoryViewMode(String mode) {
|
||||||
|
state = state.copyWith(historyViewMode: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAskQualityBeforeDownload(bool enabled) {
|
||||||
|
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyClientId(String clientId) {
|
||||||
|
state = state.copyWith(spotifyClientId: clientId);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyClientSecret(String clientSecret) {
|
||||||
|
state = state.copyWith(spotifyClientSecret: clientSecret);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyCredentials(String clientId, String clientSecret) {
|
||||||
|
state = state.copyWith(
|
||||||
|
spotifyClientId: clientId,
|
||||||
|
spotifyClientSecret: clientSecret,
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSpotifyCredentials() {
|
||||||
|
state = state.copyWith(
|
||||||
|
spotifyClientId: '',
|
||||||
|
spotifyClientSecret: '',
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUseCustomSpotifyCredentials(bool enabled) {
|
||||||
|
state = state.copyWith(useCustomSpotifyCredentials: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMetadataSource(String source) {
|
||||||
|
state = state.copyWith(metadataSource: source);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setEnableLogging(bool enabled) {
|
||||||
|
state = state.copyWith(enableLogging: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
// Sync logging state to LogBuffer
|
||||||
|
LogBuffer.loggingEnabled = enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
final modeString = prefs.getString(kThemeModeKey);
|
final modeString = prefs.getString(kThemeModeKey);
|
||||||
final useDynamic = prefs.getBool(kUseDynamicColorKey);
|
final useDynamic = prefs.getBool(kUseDynamicColorKey);
|
||||||
final seedColor = prefs.getInt(kSeedColorKey);
|
final seedColor = prefs.getInt(kSeedColorKey);
|
||||||
|
final useAmoled = prefs.getBool(kUseAmoledKey);
|
||||||
|
|
||||||
state = ThemeSettings(
|
state = ThemeSettings(
|
||||||
themeMode: _themeModeFromString(modeString),
|
themeMode: _themeModeFromString(modeString),
|
||||||
useDynamicColor: useDynamic ?? true,
|
useDynamicColor: useDynamic ?? true,
|
||||||
seedColorValue: seedColor ?? kDefaultSeedColor,
|
seedColorValue: seedColor ?? kDefaultSeedColor,
|
||||||
|
useAmoled: useAmoled ?? false,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading theme settings: $e');
|
debugPrint('Error loading theme settings: $e');
|
||||||
@@ -43,6 +45,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
await prefs.setString(kThemeModeKey, state.themeMode.name);
|
await prefs.setString(kThemeModeKey, state.themeMode.name);
|
||||||
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
|
||||||
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
await prefs.setInt(kSeedColorKey, state.seedColorValue);
|
||||||
|
await prefs.setBool(kUseAmoledKey, state.useAmoled);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error saving theme settings: $e');
|
debugPrint('Error saving theme settings: $e');
|
||||||
}
|
}
|
||||||
@@ -72,6 +75,12 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable or disable AMOLED mode (pure black background)
|
||||||
|
Future<void> setUseAmoled(bool value) async {
|
||||||
|
state = state.copyWith(useAmoled: value);
|
||||||
|
await _saveToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper to convert string to ThemeMode
|
/// 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;
|
||||||
|
|||||||
@@ -1,112 +1,283 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('TrackProvider');
|
||||||
|
|
||||||
class TrackState {
|
class TrackState {
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final String? albumId;
|
||||||
final String? albumName;
|
final String? albumName;
|
||||||
final String? playlistName;
|
final String? playlistName;
|
||||||
|
final String? artistId;
|
||||||
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
|
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
|
final bool hasSearchText; // For back button handling
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.albumId,
|
||||||
this.albumName,
|
this.albumName,
|
||||||
this.playlistName,
|
this.playlistName,
|
||||||
|
this.artistId,
|
||||||
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.artistAlbums,
|
||||||
|
this.searchArtists,
|
||||||
|
this.hasSearchText = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||||
|
|
||||||
TrackState copyWith({
|
TrackState copyWith({
|
||||||
List<Track>? tracks,
|
List<Track>? tracks,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
String? error,
|
String? error,
|
||||||
|
String? albumId,
|
||||||
String? albumName,
|
String? albumName,
|
||||||
String? playlistName,
|
String? playlistName,
|
||||||
|
String? artistId,
|
||||||
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
|
List<ArtistAlbum>? artistAlbums,
|
||||||
|
List<SearchArtist>? searchArtists,
|
||||||
|
bool? hasSearchText,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
tracks: tracks ?? this.tracks,
|
tracks: tracks ?? this.tracks,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
|
albumId: albumId ?? this.albumId,
|
||||||
albumName: albumName ?? this.albumName,
|
albumName: albumName ?? this.albumName,
|
||||||
playlistName: playlistName ?? this.playlistName,
|
playlistName: playlistName ?? this.playlistName,
|
||||||
|
artistId: artistId ?? this.artistId,
|
||||||
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents an album in artist discography
|
||||||
|
class ArtistAlbum {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String releaseDate;
|
||||||
|
final int totalTracks;
|
||||||
|
final String? coverUrl;
|
||||||
|
final String albumType; // album, single, compilation
|
||||||
|
final String artists;
|
||||||
|
|
||||||
|
const ArtistAlbum({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.releaseDate,
|
||||||
|
required this.totalTracks,
|
||||||
|
this.coverUrl,
|
||||||
|
required this.albumType,
|
||||||
|
required this.artists,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an artist in search results
|
||||||
|
class SearchArtist {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? imageUrl;
|
||||||
|
final int followers;
|
||||||
|
final int popularity;
|
||||||
|
|
||||||
|
const SearchArtist({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.followers,
|
||||||
|
required this.popularity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class TrackNotifier extends Notifier<TrackState> {
|
class TrackNotifier extends Notifier<TrackState> {
|
||||||
|
/// Request ID to track and cancel outdated requests
|
||||||
|
int _currentRequestId = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TrackState build() {
|
TrackState build() {
|
||||||
return const TrackState();
|
return const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url) async {
|
/// Check if request is still valid (not cancelled by newer request)
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
|
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||||
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
|
// Preserve hasSearchText during fetch
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
// Use the new fallback-enabled method
|
||||||
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
|
||||||
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[FetchURL] Metadata fetch success');
|
||||||
|
} catch (e) {
|
||||||
|
// If fallback also fails, show error
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[FetchURL] Metadata fetch failed: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
state = state.copyWith(
|
state = TrackState(
|
||||||
tracks: [track],
|
tracks: [track],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumName: null,
|
|
||||||
playlistName: null,
|
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
);
|
);
|
||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
state = state.copyWith(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
albumId: parsed['id'] as String?,
|
||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
playlistName: null,
|
|
||||||
coverUrl: albumInfo['images'] as String?,
|
coverUrl: albumInfo['images'] as String?,
|
||||||
);
|
);
|
||||||
|
// Pre-warm cache for album tracks in background
|
||||||
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||||
state = state.copyWith(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumName: null,
|
|
||||||
playlistName: owner?['name'] as String?,
|
playlistName: owner?['name'] as String?,
|
||||||
coverUrl: owner?['images'] as String?,
|
coverUrl: owner?['images'] as String?,
|
||||||
);
|
);
|
||||||
|
// Pre-warm cache for playlist tracks in background
|
||||||
|
_preWarmCacheForTracks(tracks);
|
||||||
|
} else if (type == 'artist') {
|
||||||
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
|
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
state = TrackState(
|
||||||
|
tracks: [], // No tracks for artist view
|
||||||
|
isLoading: false,
|
||||||
|
artistId: artistInfo['id'] as String?,
|
||||||
|
artistName: artistInfo['name'] as String?,
|
||||||
|
coverUrl: artistInfo['images'] as String?,
|
||||||
|
artistAlbums: albums,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(isLoading: false, error: e.toString());
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
// Preserve hasSearchText on error so user stays on search screen
|
||||||
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query) async {
|
Future<void> search(String query, {String? metadataSource}) async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
|
// Preserve hasSearchText during search
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await PlatformBridge.searchSpotify(query, limit: 20);
|
// Use Deezer or Spotify based on settings
|
||||||
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
|
_log.i('Search started: source=$source, query="$query"');
|
||||||
|
|
||||||
|
Map<String, dynamic> results;
|
||||||
|
if (source == 'deezer') {
|
||||||
|
_log.d('Calling Deezer search API...');
|
||||||
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
|
} else {
|
||||||
|
_log.d('Calling Spotify search API...');
|
||||||
|
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isRequestValid(requestId)) {
|
||||||
|
_log.w('Search request cancelled (requestId=$requestId)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
state = state.copyWith(
|
|
||||||
|
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||||
|
|
||||||
|
// Parse tracks with error handling per item
|
||||||
|
final tracks = <Track>[];
|
||||||
|
for (int i = 0; i < trackList.length; i++) {
|
||||||
|
final t = trackList[i];
|
||||||
|
try {
|
||||||
|
if (t is Map<String, dynamic>) {
|
||||||
|
tracks.add(_parseSearchTrack(t));
|
||||||
|
} else {
|
||||||
|
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to parse track[$i]: $e', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse artists with error handling per item
|
||||||
|
final artists = <SearchArtist>[];
|
||||||
|
for (int i = 0; i < artistList.length; i++) {
|
||||||
|
final a = artistList[i];
|
||||||
|
try {
|
||||||
|
if (a is Map<String, dynamic>) {
|
||||||
|
artists.add(_parseSearchArtist(a));
|
||||||
|
} else {
|
||||||
|
_log.w('Artist[$i] is not a Map: ${a.runtimeType}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to parse artist[$i]: $e', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully');
|
||||||
|
|
||||||
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
|
searchArtists: artists,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumName: null,
|
hasSearchText: state.hasSearchText,
|
||||||
playlistName: null,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
state = state.copyWith(isLoading: false, error: e.toString());
|
if (!_isRequestValid(requestId)) return;
|
||||||
|
_log.e('Search failed: $e', e, stackTrace);
|
||||||
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +323,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = const TrackState();
|
state = const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set search text state for back button handling
|
||||||
|
void setSearchText(bool hasText) {
|
||||||
|
state = state.copyWith(hasSearchText: hasText);
|
||||||
|
}
|
||||||
|
|
||||||
Track _parseTrack(Map<String, dynamic> data) {
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: data['spotify_id'] as String? ?? '',
|
||||||
@@ -161,7 +337,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
@@ -169,20 +345,73 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Track _parseSearchTrack(Map<String, dynamic> data) {
|
Track _parseSearchTrack(Map<String, dynamic> data) {
|
||||||
|
// Handle duration_ms which might be int or double
|
||||||
|
int durationMs = 0;
|
||||||
|
final durationValue = data['duration_ms'];
|
||||||
|
if (durationValue is int) {
|
||||||
|
durationMs = durationValue;
|
||||||
|
} else if (durationValue is double) {
|
||||||
|
durationMs = durationValue.toInt();
|
||||||
|
}
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||||
name: data['name'] as String? ?? '',
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: data['artists'] as String? ?? '',
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: data['album_name'] as String? ?? '',
|
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist']?.toString(),
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images']?.toString(),
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc']?.toString(),
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
|
return ArtistAlbum(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
|
artists: data['artists'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchArtist _parseSearchArtist(Map<String, dynamic> data) {
|
||||||
|
return SearchArtist(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
imageUrl: data['images'] as String?,
|
||||||
|
followers: data['followers'] as int? ?? 0,
|
||||||
|
popularity: data['popularity'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-warm track ID cache for faster downloads
|
||||||
|
/// Runs in background, doesn't block UI
|
||||||
|
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||||
|
// Only pre-warm if we have tracks with ISRC
|
||||||
|
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||||
|
if (tracksWithIsrc.isEmpty) return;
|
||||||
|
|
||||||
|
// Build request list for Go backend
|
||||||
|
final cacheRequests = tracksWithIsrc.map((t) => {
|
||||||
|
'isrc': t.isrc!,
|
||||||
|
'track_name': t.name,
|
||||||
|
'artist_name': t.artistName,
|
||||||
|
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
||||||
|
'service': 'tidal', // Default to tidal for pre-warming
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Fire and forget - runs in background
|
||||||
|
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
|
||||||
|
// Silently ignore errors - this is just an optimization
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||||
|
|||||||
@@ -0,0 +1,738 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
|
/// Simple in-memory cache for album tracks
|
||||||
|
class _AlbumCache {
|
||||||
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
|
static List<Track>? get(String albumId) {
|
||||||
|
final entry = _cache[albumId];
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
|
_cache.remove(albumId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set(String albumId, List<Track> tracks) {
|
||||||
|
_cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheEntry {
|
||||||
|
final List<Track> tracks;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
_CacheEntry(this.tracks, this.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album detail screen with Material Expressive 3 design
|
||||||
|
class AlbumScreen extends ConsumerStatefulWidget {
|
||||||
|
final String albumId;
|
||||||
|
final String albumName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<Track>? tracks; // Optional - will fetch if null
|
||||||
|
|
||||||
|
const AlbumScreen({
|
||||||
|
super.key,
|
||||||
|
required this.albumId,
|
||||||
|
required this.albumName,
|
||||||
|
this.coverUrl,
|
||||||
|
this.tracks,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||||
|
List<Track>? _tracks;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Priority: widget.tracks > cache > fetch
|
||||||
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
|
if (_tracks == null) {
|
||||||
|
_fetchTracks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchTracks() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
|
// Check if this is a Deezer album ID (format: "deezer:123456")
|
||||||
|
if (widget.albumId.startsWith('deezer:')) {
|
||||||
|
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
||||||
|
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
||||||
|
} else {
|
||||||
|
// Spotify album - use fallback method
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
||||||
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
|
return Track(
|
||||||
|
id: data['spotify_id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
artistName: data['artists'] as String? ?? '',
|
||||||
|
albumName: data['album_name'] as String? ?? '',
|
||||||
|
albumArtist: data['album_artist'] as String?,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
isrc: data['isrc'] as String?,
|
||||||
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
|
trackNumber: data['track_number'] as int?,
|
||||||
|
discNumber: data['disc_number'] as int?,
|
||||||
|
releaseDate: data['release_date'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final tracks = _tracks ?? [];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme),
|
||||||
|
if (_isLoading)
|
||||||
|
const SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)),
|
||||||
|
if (_error != null)
|
||||||
|
SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
|
)),
|
||||||
|
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||||
|
_buildTrackListHeader(context, colorScheme),
|
||||||
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
|
],
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.coverUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
memCacheWidth: 600,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final tracks = _tracks ?? [];
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.albumName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (tracks.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (tracks.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _downloadAll(context),
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: Text('Download All (${tracks.length})'),
|
||||||
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _AlbumTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, track),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadTrack(BuildContext context, Track track) {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, (quality, service) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadAll(BuildContext context) {
|
||||||
|
final tracks = _tracks;
|
||||||
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, (quality, service) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
String selectedService = settings.defaultService;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) => SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (trackName != null) ...[
|
||||||
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
|
],
|
||||||
|
// Service selector
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
|
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
|
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build error widget with special handling for rate limit (429)
|
||||||
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
|
final isRateLimit = error.contains('429') ||
|
||||||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
|
if (isRateLimit) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Rate Limited',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Too many requests. Please wait a moment and try again.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error display
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QualityOption extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
|
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServiceChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
|
final String trackName;
|
||||||
|
final String? artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||||
|
bool _expanded = false;
|
||||||
|
bool _isOverflowing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||||
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||||
|
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||||
|
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||||
|
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||||
|
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _isOverflowing != titleOverflows) {
|
||||||
|
setState(() => _isOverflowing = titleOverflows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.trackName,
|
||||||
|
style: titleStyle,
|
||||||
|
maxLines: _expanded ? 10 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (widget.artistName != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.artistName!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: _expanded ? 3 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isOverflowing || _expanded)
|
||||||
|
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
class _AlbumTrackItem extends ConsumerWidget {
|
||||||
|
final Track track;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
|
||||||
|
const _AlbumTrackItem({required this.track, required this.onDownload});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Only watch the specific item for this track
|
||||||
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if track is in history (already downloaded before)
|
||||||
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
|
return state.isDownloaded(track.id);
|
||||||
|
}));
|
||||||
|
|
||||||
|
final isQueued = queueItem != null;
|
||||||
|
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||||
|
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||||
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
|
// Show as downloaded if in queue completed OR in history
|
||||||
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
leading: track.coverUrl != null
|
||||||
|
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||||
|
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||||
|
if (isQueued) return;
|
||||||
|
|
||||||
|
if (isInHistory) {
|
||||||
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||||
|
if (historyItem != null) {
|
||||||
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
|
if (fileExists) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||||
|
required bool isQueued,
|
||||||
|
required bool isDownloading,
|
||||||
|
required bool isFinalizing,
|
||||||
|
required bool showAsDownloaded,
|
||||||
|
required bool isInHistory,
|
||||||
|
required double progress,
|
||||||
|
}) {
|
||||||
|
const double size = 44.0;
|
||||||
|
const double iconSize = 20.0;
|
||||||
|
|
||||||
|
if (showAsDownloaded) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
} else if (isFinalizing) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isDownloading) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isQueued) {
|
||||||
|
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||||
|
} else {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onDownload,
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||