Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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...
|
||||||
@@ -3,13 +3,13 @@ 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:
|
||||||
# Get version first (quick job)
|
# Get version first (quick job)
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
fi
|
fi
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||||
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||||
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
build-android:
|
build-android:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: get-version
|
needs: get-version
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Free disk space
|
- name: Free disk space
|
||||||
run: |
|
run: |
|
||||||
@@ -65,13 +65,13 @@ jobs:
|
|||||||
- 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
|
# Cache Gradle for faster builds
|
||||||
@@ -85,7 +85,20 @@ jobs:
|
|||||||
restore-keys: gradle-${{ runner.os }}-
|
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: |
|
||||||
@@ -103,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
|
||||||
@@ -113,7 +126,14 @@ jobs:
|
|||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build APK (Release - unsigned)
|
- 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
|
- name: Sign APKs
|
||||||
uses: r0adkll/sign-android-release@v1
|
uses: r0adkll/sign-android-release@v1
|
||||||
@@ -125,7 +145,7 @@ jobs:
|
|||||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
env:
|
env:
|
||||||
BUILD_TOOLS_VERSION: "34.0.0"
|
BUILD_TOOLS_VERSION: "36.0.0"
|
||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
@@ -145,8 +165,8 @@ jobs:
|
|||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: get-version # Only depends on version, NOT android build!
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -154,7 +174,7 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: "1.21"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
@@ -182,51 +202,51 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
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)
|
||||||
|
|
||||||
- name: Add XCFramework to Xcode project
|
- name: Add XCFramework to Xcode project
|
||||||
run: |
|
run: |
|
||||||
# Install xcodeproj gem for modifying Xcode project
|
# Install xcodeproj gem for modifying Xcode project
|
||||||
sudo gem install xcodeproj
|
sudo gem install xcodeproj
|
||||||
|
|
||||||
# Create Ruby script to add framework
|
# Create Ruby script to add framework
|
||||||
cat > add_framework.rb << 'EOF'
|
cat > add_framework.rb << 'EOF'
|
||||||
require 'xcodeproj'
|
require 'xcodeproj'
|
||||||
|
|
||||||
project_path = 'ios/Runner.xcodeproj'
|
project_path = 'ios/Runner.xcodeproj'
|
||||||
project = Xcodeproj::Project.open(project_path)
|
project = Xcodeproj::Project.open(project_path)
|
||||||
|
|
||||||
# Get the main target
|
# Get the main target
|
||||||
target = project.targets.find { |t| t.name == 'Runner' }
|
target = project.targets.find { |t| t.name == 'Runner' }
|
||||||
|
|
||||||
# Get or create Frameworks group
|
# Get or create Frameworks group
|
||||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||||
|
|
||||||
# Add XCFramework reference
|
# Add XCFramework reference
|
||||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||||
|
|
||||||
# Add to frameworks build phase
|
# Add to frameworks build phase
|
||||||
frameworks_build_phase = target.frameworks_build_phase
|
frameworks_build_phase = target.frameworks_build_phase
|
||||||
frameworks_build_phase.add_file_reference(framework_ref)
|
frameworks_build_phase.add_file_reference(framework_ref)
|
||||||
|
|
||||||
# Add to embed frameworks build phase
|
# Add to embed frameworks build phase
|
||||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||||
if embed_phase
|
if embed_phase
|
||||||
build_file = embed_phase.add_file_reference(framework_ref)
|
build_file = embed_phase.add_file_reference(framework_ref)
|
||||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||||
end
|
end
|
||||||
|
|
||||||
project.save
|
project.save
|
||||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
ruby add_framework.rb
|
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)
|
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
||||||
@@ -253,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.get-version.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:
|
||||||
@@ -276,7 +322,7 @@ jobs:
|
|||||||
needs: [get-version, 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
|
||||||
@@ -286,13 +332,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||||
|
|
||||||
echo "Looking for version: $VERSION_NUM"
|
echo "Looking for version: $VERSION_NUM"
|
||||||
|
|
||||||
# Extract changelog section for this version using sed
|
# Extract changelog section for this version using sed
|
||||||
# Find the line with version, then print until next version header or end
|
# 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)
|
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||||
|
|
||||||
# If no changelog found, use default message
|
# If no changelog found, use default message
|
||||||
if [ -z "$CHANGELOG" ]; then
|
if [ -z "$CHANGELOG" ]; then
|
||||||
echo "No changelog found for version $VERSION_NUM"
|
echo "No changelog found for version $VERSION_NUM"
|
||||||
@@ -300,7 +346,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Found changelog content"
|
echo "Found changelog content"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Save to file for multiline support
|
# Save to file for multiline support
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
echo "Extracted changelog:"
|
echo "Extracted changelog:"
|
||||||
@@ -322,32 +368,34 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cat > /tmp/release_body.txt << 'HEADER'
|
cat > /tmp/release_body.txt << 'HEADER'
|
||||||
## SpotiFLAC $VERSION
|
|
||||||
|
|
||||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
|
||||||
|
|
||||||
### What's New
|
### What's New
|
||||||
HEADER
|
HEADER
|
||||||
|
|
||||||
# Replace $VERSION in header
|
|
||||||
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
|
|
||||||
|
|
||||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
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
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Downloads
|
### Downloads
|
||||||
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
|
||||||
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
#### 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)
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
|
  
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
cat /tmp/release_body.txt
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ Thumbs.db
|
|||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
# Development notes
|
|
||||||
COMPARISON_PC_vs_ANDROID.md
|
|
||||||
|
|
||||||
# Old spotiflac_android folder (moved to root)
|
# Old spotiflac_android folder (moved to root)
|
||||||
spotiflac_android/
|
spotiflac_android/
|
||||||
|
|
||||||
@@ -38,7 +35,7 @@ go_backend/*.xcframework/
|
|||||||
|
|
||||||
# Android
|
# Android
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/app/libs/
|
android/app/libs/gobackend.aar
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/*.iml
|
android/*.iml
|
||||||
android/key.properties
|
android/key.properties
|
||||||
@@ -52,3 +49,4 @@ ios/Pods/
|
|||||||
ios/.symlinks/
|
ios/.symlinks/
|
||||||
ios/Flutter/Flutter.framework/
|
ios/Flutter/Flutter.framework/
|
||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
|
android/app/libs/gobackend-sources.jar
|
||||||
|
|||||||
@@ -1,8 +1,387 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.2.5] - 2026-01-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **In-App Log Viewer with Go Backend Logs**: Complete logging system for debugging
|
||||||
|
- Go backend logs now captured and displayed in app
|
||||||
|
- Circular buffer stores up to 500 log entries
|
||||||
|
- Real-time polling (500ms) for Go backend logs
|
||||||
|
- Logs include timestamp, level, tag, and message
|
||||||
|
- "Go" badge indicates logs from backend
|
||||||
|
- **Detailed Logging Toggle**: Control logging in Settings > Options > Debug
|
||||||
|
- Disabled by default for performance
|
||||||
|
- Errors are always logged regardless of setting
|
||||||
|
- Enable before reproducing bugs for detailed logs
|
||||||
|
- **Log Issue Summary**: Automatic detection of common issues in logs
|
||||||
|
- ISP Blocking detection with affected domains
|
||||||
|
- Rate limiting detection
|
||||||
|
- Network error detection
|
||||||
|
- Track not found detection
|
||||||
|
- Shows suggestions for each issue type
|
||||||
|
- **ISP Blocking Detection**: Detects when ISP blocks download services
|
||||||
|
- DNS resolution failure detection
|
||||||
|
- Connection reset/refused detection
|
||||||
|
- TLS handshake failure detection
|
||||||
|
- HTTP 403/451 blocking page detection
|
||||||
|
- Suggests VPN or DNS change (1.1.1.1 / 8.8.8.8)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Artist Profile Placeholder**: Shows person icon when artist has no profile image
|
||||||
|
- Validates image URL before loading
|
||||||
|
- Fallback icon on load error
|
||||||
|
- **Latin Extended Character Detection**: Fixed wrong track downloads for Polish, Czech, French, Spanish songs
|
||||||
|
- Characters like Ł, ę, ć, ñ, é now correctly treated as Latin script
|
||||||
|
- Previously treated as "different script" causing false matches
|
||||||
|
- Affects both Tidal and Qobuz search
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Log Screen UI Improvements**:
|
||||||
|
- Copy button moved to app bar (left of menu)
|
||||||
|
- Removed redundant info card
|
||||||
|
- Cleaner interface
|
||||||
|
- **Issue Templates Updated**: Instructions for enabling detailed logging before submitting bug reports
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- New file: `go_backend/logbuffer.go` with circular buffer and GoLog function
|
||||||
|
- Updated `go_backend/httputil.go` with ISP blocking detection
|
||||||
|
- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with `isLatinScript()` function
|
||||||
|
- Updated `lib/utils/logger.dart` with Go log polling
|
||||||
|
- Updated `lib/screens/settings/log_screen.dart` with issue summary
|
||||||
|
- Added method channel handlers for logging in Android and iOS
|
||||||
|
- New error type: `isp_blocked` for ISP blocking errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.0] - 2026-01-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ISRC Metadata Missing:** Fixed an issue where ISRC codes were not being saved to the download history or embedded in file metadata for certain downloads. The backend now correctly propagates the ISRC found from streaming services (Tidal, Qobuz, Amazon) back to the application.
|
||||||
|
- **Tidal Track/Disc Numbers:** Fixed missing Track Number and Disc Number in Tidal downloads. The downloader now prioritizes the actual metadata returned by Tidal over the potentially incomplete metadata from the initial search request.
|
||||||
|
- **Concurrent Download Race Condition:** Fixed a potential race condition where temporary cover art files could overwrite each other during rapid concurrent downloads by adding randomization to temporary filenames.
|
||||||
|
- **Qobuz Search Accuracy:** Reduced the duration tolerance for Qobuz search matches from 30s to 10s to prevent matching with incorrect versions/remixes.
|
||||||
|
- **Metadata Enrichment Null Safety**: Fixed `type 'Null' is not a subtype of type 'String'` error
|
||||||
|
- Added proper null checks when parsing Go backend response
|
||||||
|
- Added type checking for track data before parsing
|
||||||
|
- **Duration Calculation in Enrichment**: Fixed duration conversion bug
|
||||||
|
- Go backend returns `duration_ms` (milliseconds)
|
||||||
|
- Now properly converts to seconds for Track model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Default Service Priority:** Updated the default download fallback order to **Tidal → Qobuz → Amazon**.
|
||||||
|
- Tidal is now the default download service (was Qobuz)
|
||||||
|
- Tidal has faster and more reliable ISRC matching
|
||||||
|
- Existing users need to change setting manually or clear app data
|
||||||
|
- **Metadata Enrichment:** Improved metadata handling for Deezer tracks. If critical metadata (ISRC, Track Number) is missing from the initial search, the app now automatically fetches full details from the Deezer API before finding a source.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **ISRC in History:** The Download History now reliably displays the ISRC code for downloaded tracks.
|
||||||
|
- **Tidal Search Optimization:** Optimized Tidal search logic to immediately check for ISRC matches within search results, improving match speed and accuracy.
|
||||||
|
- Returns as soon as ISRC match is found in first query results
|
||||||
|
- Significantly faster for tracks with valid ISRC
|
||||||
|
- **ISRC Enrichment for Search Results**: Tracks from Home search now fetch ISRC before download
|
||||||
|
- Search results don't include ISRC (for performance)
|
||||||
|
- ISRC is now fetched via metadata enrichment when download starts
|
||||||
|
- Ensures accurate track matching on all streaming services
|
||||||
|
- **Deezer-to-Tidal Fallback:** Added native support for converting Deezer IDs to Tidal links via SongLink when using the fallback mechanism.
|
||||||
|
- **Better Logging for Qobuz ISRC Search**: Added detailed logs for debugging
|
||||||
|
- Shows when ISRC search is attempted
|
||||||
|
- Shows number of results and exact ISRC matches found
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Updated `go_backend/tidal.go`:
|
||||||
|
- Early exit optimization in `SearchTrackByMetadataWithISRC()`
|
||||||
|
- Deezer ID support in SongLink lookup
|
||||||
|
- Updated `go_backend/qobuz.go`:
|
||||||
|
- Added logging for ISRC search flow
|
||||||
|
- Duration tolerance reduced from 30s to 10s
|
||||||
|
- Updated `go_backend/exports.go`:
|
||||||
|
- Default service order changed to `[tidal, qobuz, amazon]`
|
||||||
|
- Updated `lib/providers/download_queue_provider.dart`:
|
||||||
|
- ISRC-based enrichment condition
|
||||||
|
- Null-safe parsing of Go backend response
|
||||||
|
- Updated `lib/services/platform_bridge.dart`:
|
||||||
|
- Null check for `getDeezerMetadata` result
|
||||||
|
- Updated `lib/models/settings.dart`:
|
||||||
|
- Default service changed to `tidal`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.7] - 2026-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Special Thanks Section**: Added new "Special Thanks" section in About page to credit API creators
|
||||||
|
- **uimaxbai** - Creator of QQDL & HiFi API for Tidal downloads
|
||||||
|
- **sachinsenal0x64** - Original HiFi project creator, foundation of Tidal integration
|
||||||
|
- **DoubleDouble** - Amazing API for Amazon Music downloads
|
||||||
|
- **DAB Music** - The best Qobuz streaming API for Hi-Res downloads
|
||||||
|
- **New Contributor**: Added Amonoman to Contributors section as the app logo creator
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing PlatformBridge Import**: Fixed build errors in `home_tab.dart` and `playlist_screen.dart`
|
||||||
|
- Added missing `import 'package:spotiflac_android/services/platform_bridge.dart'`
|
||||||
|
- **iOS Method Channel Crash**: Fixed "Method not implemented" crash when searching Deezer from iOS
|
||||||
|
- Implemented missing `searchDeezerAll` handler in `AppDelegate.swift`
|
||||||
|
- Ensures full compatibility with new Deezer integration features on iOS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.6] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Metadata Enrichment**: Automatically fetches full track details if metadata is incomplete (e.g., Track Number 0)
|
||||||
|
- Fixes missing Track Number, Disc Number, and Year for tracks added from Search results
|
||||||
|
- Ensures accurate tagging for Deezer/Tidal downloads
|
||||||
|
- **ISRC Index Building**: Fast duplicate checking with cached ISRC index
|
||||||
|
|
||||||
|
- Scans download folder once and builds index of all ISRCs
|
||||||
|
- 5 minute cache TTL for optimal performance
|
||||||
|
- Parallel duplicate checking for album/playlist tracks
|
||||||
|
- Auto-adds new downloads to index (no rebuild needed)
|
||||||
|
|
||||||
|
- **Japanese to Romaji Search**: Better search results for Japanese tracks
|
||||||
|
|
||||||
|
- Converts Hiragana/Katakana to Romaji for Tidal/Qobuz search
|
||||||
|
- 4 fallback search strategies (like PC version):
|
||||||
|
1. Original text (artist + track)
|
||||||
|
2. Romaji converted (artist + track)
|
||||||
|
3. ASCII-only cleaned version
|
||||||
|
4. Artist name only as last resort
|
||||||
|
- Handles combination characters (きゃ →kya, シャ →sha, etc.)
|
||||||
|
|
||||||
|
- **SongLink Deezer Support**: Query SongLink using Deezer ID as source
|
||||||
|
|
||||||
|
- `CheckAvailabilityFromDeezer()` - find track on other platforms using Deezer ID
|
||||||
|
- `CheckAvailabilityByPlatform()` - generic function for any platform
|
||||||
|
- `GetSpotifyIDFromDeezer()`, `GetTidalURLFromDeezer()`, `GetAmazonURLFromDeezer()`
|
||||||
|
- Useful when starting from Deezer metadata
|
||||||
|
|
||||||
|
- **LRC Metadata Headers**: Lyrics now include metadata headers
|
||||||
|
|
||||||
|
- `[ti:Track Name]` - track title
|
||||||
|
- `[ar:Artist Name]` - artist name
|
||||||
|
- `[by:SpotiFLAC-Mobile]` - generator tag
|
||||||
|
|
||||||
|
- **Download Error Types**: Better error categorization for UI
|
||||||
|
|
||||||
|
- `not_found` - track not available on any service
|
||||||
|
- `rate_limit` - API rate limit exceeded
|
||||||
|
- `network` - connection/timeout errors
|
||||||
|
- `unknown` - other errors
|
||||||
|
|
||||||
|
- **Amazon Rate Limiting**: Proper rate limiting for Amazon via SongLink
|
||||||
|
- 7 second minimum delay between requests
|
||||||
|
- Max 9 requests per minute
|
||||||
|
- 3x retry with 15s wait on 429 rate limit
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **SongLink 400 Error**: Added validation for empty Spotify ID
|
||||||
|
|
||||||
|
- Specific error messages for 400, 404, 429 status codes
|
||||||
|
- Better error handling for invalid track IDs
|
||||||
|
|
||||||
|
- **gomobile Compatibility**: Fixed `ISRCIndex.Lookup()` signature
|
||||||
|
- Changed from `(string, bool)` to `(string, error)` for gomobile binding
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- New file: `go_backend/romaji.go` with Japanese to Romaji conversion
|
||||||
|
- New file: `go_backend/duplicate.go` with ISRC index building
|
||||||
|
- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with romaji search strategies
|
||||||
|
- Updated `go_backend/songlink.go` with Deezer support functions
|
||||||
|
- Updated `go_backend/exports.go` with new export functions for Flutter
|
||||||
|
- Updated `go_backend/lyrics.go` with `convertToLRCWithMetadata()`
|
||||||
|
- Updated `go_backend/progress.go` with `SpeedMBps` field
|
||||||
|
- Updated `lib/models/download_item.dart` with `DownloadErrorType` enum
|
||||||
|
- Updated `lib/screens/queue_tab.dart` with speed display and error messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.6-preview] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search
|
||||||
|
|
||||||
|
- Configure in Settings > Options > Spotify API > Search Source
|
||||||
|
- Default is Deezer for better reliability
|
||||||
|
- Spotify URLs are always supported regardless of this setting
|
||||||
|
|
||||||
|
- **Automatic Deezer Fallback for Spotify URLs**: When Spotify API is rate limited (429), automatically falls back to Deezer
|
||||||
|
- Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID
|
||||||
|
- Fetches metadata from Deezer instead
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Default Download Service**: Changed from Tidal to Qobuz
|
||||||
|
- Fallback order is now: Qobuz → Tidal → Amazon
|
||||||
|
- **Deezer API Updated to v2.0**: More reliable and complete metadata
|
||||||
|
- Direct ISRC lookup via `/track/isrc:{ISRC}` endpoint
|
||||||
|
- Search results now fetch full track info to include ISRC
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
|
||||||
|
- Progress now updates smoothly every 64KB of data received
|
||||||
|
- First progress update happens immediately when download starts
|
||||||
|
- **Incomplete Downloads**: Fixed bug where interrupted downloads could result in corrupted/incomplete files
|
||||||
|
- File size is validated against server's Content-Length header
|
||||||
|
- Incomplete files are automatically deleted and error is reported
|
||||||
|
- Applies to all services: Tidal, Qobuz, and Amazon
|
||||||
|
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Settings migration for existing users to set Deezer as default metadata source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.5] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
||||||
|
- Service selector chips appear above quality options
|
||||||
|
- Defaults to your preferred service from settings
|
||||||
|
- Change service on-the-fly without going to settings
|
||||||
|
- Available in Home, Album, and Playlist screens
|
||||||
|
- **AMOLED Dark Theme**: Pure black background for OLED screens
|
||||||
|
- Toggle in Settings > Appearance > Theme
|
||||||
|
- Saves battery on OLED/AMOLED displays
|
||||||
|
- All surface colors adjusted for true black background
|
||||||
|
- **Update Channel Setting**: Choose between Stable and Preview release channels
|
||||||
|
- Stable: Only receive stable release notifications
|
||||||
|
- Preview: Get notified about preview/beta releases too
|
||||||
|
- Configure in Settings > Options > App
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
||||||
|
- arm64 APK: 46.6 MB (previously 51 MB)
|
||||||
|
- arm32 APK: 59 MB (previously 64 MB)
|
||||||
|
- Only includes FLAC, MP3 (LAME), and AAC codecs
|
||||||
|
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
|
||||||
|
- Native MethodChannel bridge for FFmpeg operations
|
||||||
|
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
||||||
|
- Now properly handles retry when queue processing has finished
|
||||||
|
- Also allows retrying skipped (cancelled) downloads
|
||||||
|
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
|
||||||
|
- Shows "Lyrics not available" instead of loading forever
|
||||||
|
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
|
||||||
|
- iOS limitation: Empty folders cannot be selected via document picker
|
||||||
|
- Added "App Documents Folder" option as recommended default
|
||||||
|
- Files saved to app Documents folder are accessible via iOS Files app
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
||||||
|
- Token caching for Tidal (eliminates redundant auth requests)
|
||||||
|
- Singleton pattern for all downloaders (HTTP connection reuse)
|
||||||
|
- ISRC search first strategy (faster than SongLink API)
|
||||||
|
- Track ID cache with 30 minute TTL for album/playlist downloads
|
||||||
|
- Pre-warm cache when viewing album/playlist
|
||||||
|
- Parallel cover art and lyrics fetching during audio download
|
||||||
|
- 64KB HTTP read/write buffers
|
||||||
|
- 256KB buffered file writer for all downloaders
|
||||||
|
- Progress updates every 64KB (reduced lock contention)
|
||||||
|
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
|
||||||
|
|
||||||
|
## [2.1.0-preview2] - 2026-01-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
||||||
|
- Service selector chips appear above quality options
|
||||||
|
- Defaults to your preferred service from settings
|
||||||
|
- Change service on-the-fly without going to settings
|
||||||
|
- Available in Home, Album, and Playlist screens
|
||||||
|
- **AMOLED Dark Theme**: Pure black background for OLED screens
|
||||||
|
- Toggle in Settings > Appearance > Theme
|
||||||
|
- Saves battery on OLED/AMOLED displays
|
||||||
|
- All surface colors adjusted for true black background
|
||||||
|
- **Update Channel Setting**: Choose between Stable and Preview release channels
|
||||||
|
- Stable: Only receive stable release notifications
|
||||||
|
- Preview: Get notified about preview/beta releases too
|
||||||
|
- Configure in Settings > Options > App
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
||||||
|
- Now properly handles retry when queue processing has finished
|
||||||
|
- Also allows retrying skipped (cancelled) downloads
|
||||||
|
- Added logging for better debugging
|
||||||
|
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
|
||||||
|
- Shows "Lyrics not available" instead of loading forever
|
||||||
|
- Better error messages for timeout and not found cases
|
||||||
|
|
||||||
|
## [2.1.0-preview] - 2026-01-06
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
||||||
|
- Token caching for Tidal (eliminates redundant auth requests)
|
||||||
|
- Singleton pattern for all downloaders (HTTP connection reuse)
|
||||||
|
- ISRC search first strategy (faster than SongLink API)
|
||||||
|
- Track ID cache with 30 minute TTL for album/playlist downloads
|
||||||
|
- Pre-warm cache when viewing album/playlist
|
||||||
|
- Parallel cover art and lyrics fetching during audio download
|
||||||
|
- 64KB HTTP read/write buffers
|
||||||
|
- 256KB buffered file writer for all downloaders
|
||||||
|
- Progress updates every 64KB (reduced lock contention)
|
||||||
|
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
|
||||||
|
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
|
||||||
|
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
|
||||||
|
|
||||||
|
## [2.0.7-preview2] - 2026-01-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
|
||||||
|
- iOS limitation: Empty folders cannot be selected via document picker
|
||||||
|
- Added "App Documents Folder" option as recommended default
|
||||||
|
- Shows info message explaining iOS limitation
|
||||||
|
- Files saved to app Documents folder are accessible via iOS Files app
|
||||||
|
|
||||||
|
## [2.0.7-preview] - 2026-01-05
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
||||||
|
- arm64 APK: 46.6 MB (previously 51 MB)
|
||||||
|
- arm32 APK: 59 MB (previously 64 MB)
|
||||||
|
- Only includes FLAC, MP3 (LAME), and AAC codecs
|
||||||
|
- Removed x86/x86_64 architectures (emulator only)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
|
||||||
|
- Native MethodChannel bridge for FFmpeg operations
|
||||||
|
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
||||||
|
|
||||||
## [2.0.6] - 2026-01-05
|
## [2.0.6] - 2026-01-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
|
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
|
||||||
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
|
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
|
||||||
- Now properly converts milliseconds to seconds before display
|
- Now properly converts milliseconds to seconds before display
|
||||||
@@ -22,14 +401,17 @@
|
|||||||
## [2.0.5] - 2026-01-05
|
## [2.0.5] - 2026-01-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
|
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
|
||||||
|
|
||||||
## [2.0.4] - 2026-01-04
|
## [2.0.4] - 2026-01-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
|
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
|
||||||
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
|
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
|
||||||
- Shows explanation dialog before opening system settings
|
- Shows explanation dialog before opening system settings
|
||||||
@@ -37,6 +419,7 @@
|
|||||||
## [2.0.3] - 2026-01-03
|
## [2.0.3] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
|
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
|
||||||
- Toggle to enable/disable custom credentials without deleting them
|
- Toggle to enable/disable custom credentials without deleting them
|
||||||
- Material Expressive 3 bottom sheet UI for entering credentials
|
- Material Expressive 3 bottom sheet UI for entering credentials
|
||||||
@@ -44,9 +427,11 @@
|
|||||||
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
|
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
|
||||||
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
|
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
|
||||||
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
||||||
@@ -56,6 +441,7 @@
|
|||||||
## [2.0.2] - 2026-01-03
|
## [2.0.2] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
|
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
|
||||||
- Quality badge on download history items (e.g., "24-bit", "16-bit")
|
- Quality badge on download history items (e.g., "24-bit", "16-bit")
|
||||||
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
|
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
|
||||||
@@ -64,13 +450,16 @@
|
|||||||
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
|
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
|
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
|
||||||
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
|
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
|
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
|
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
|
||||||
- Go backend now returns `service` field indicating actual service used (important for fallback)
|
- Go backend now returns `service` field indicating actual service used (important for fallback)
|
||||||
- Tidal API v2 response provides exact quality info
|
- Tidal API v2 response provides exact quality info
|
||||||
@@ -80,18 +469,21 @@
|
|||||||
## [2.0.1] - 2026-01-03
|
## [2.0.1] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
||||||
- Tap to expand long track titles
|
- Tap to expand long track titles
|
||||||
- Expand icon only shows when title is truncated
|
- Expand icon only shows when title is truncated
|
||||||
- Ripple effect follows rounded corners including drag handle
|
- Ripple effect follows rounded corners including drag handle
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
|
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
|
||||||
- All downloads now use item-based progress tracking
|
- All downloads now use item-based progress tracking
|
||||||
- Fixes duplicate notification bug when finalizing
|
- Fixes duplicate notification bug when finalizing
|
||||||
- Cleaner codebase with single progress system
|
- Cleaner codebase with single progress system
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
|
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
|
||||||
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
|
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
|
||||||
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
|
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
|
||||||
@@ -101,6 +493,7 @@
|
|||||||
## [2.0.0] - 2026-01-03
|
## [2.0.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Artist Search Results**: Search now shows artists alongside tracks
|
- **Artist Search Results**: Search now shows artists alongside tracks
|
||||||
- Horizontal scrollable artist cards with circular avatars
|
- Horizontal scrollable artist cards with circular avatars
|
||||||
- Tap artist to view their discography
|
- Tap artist to view their discography
|
||||||
@@ -121,11 +514,12 @@
|
|||||||
- Stable users won't receive update notifications for preview versions
|
- Stable users won't receive update notifications for preview versions
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
|
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
|
||||||
- Header (name, cover) shows instantly from available data
|
- Header (name, cover) shows instantly from available data
|
||||||
- Content (albums/tracks) loads in background inside the screen
|
- Content (albums/tracks) loads in background inside the screen
|
||||||
- Second visit to same artist/album is instant from Flutter cache
|
- Second visit to same artist/album is instant from Flutter cache
|
||||||
- **Search Results UI Redesign**:
|
- **Search Results UI Redesign**:
|
||||||
- Removed "Download All" button from search results
|
- Removed "Download All" button from search results
|
||||||
- Added "Songs" section header (matches "Artists" header style)
|
- Added "Songs" section header (matches "Artists" header style)
|
||||||
- Track list now in grouped card with rounded corners (like Settings)
|
- Track list now in grouped card with rounded corners (like Settings)
|
||||||
@@ -148,6 +542,7 @@
|
|||||||
- **Ask Before Download Default**: Now enabled by default for better UX
|
- **Ask Before Download Default**: Now enabled by default for better UX
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
||||||
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
|
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
|
||||||
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
|
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
|
||||||
@@ -158,6 +553,7 @@
|
|||||||
## [1.6.3] - 2026-01-03
|
## [1.6.3] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
|
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
|
||||||
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
|
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
|
||||||
- Collapsing header with cover art and gradient overlay
|
- Collapsing header with cover art and gradient overlay
|
||||||
@@ -167,6 +563,7 @@
|
|||||||
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
|
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
|
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
|
||||||
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
|
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
|
||||||
- Enables native predictive back gesture animations
|
- Enables native predictive back gesture animations
|
||||||
@@ -176,17 +573,21 @@
|
|||||||
## [1.6.2] - 2026-01-02
|
## [1.6.2] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
||||||
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
||||||
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
|
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
|
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
|
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
|
||||||
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
|
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
|
||||||
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
||||||
@@ -198,12 +599,14 @@
|
|||||||
## [1.6.1] - 2026-01-02
|
## [1.6.1] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Background Download Service**: Downloads now continue running when app is in background
|
- **Background Download Service**: Downloads now continue running when app is in background
|
||||||
- Foreground service with wake lock prevents Android from killing downloads
|
- Foreground service with wake lock prevents Android from killing downloads
|
||||||
- Persistent notification shows download progress
|
- Persistent notification shows download progress
|
||||||
- No more "connection abort" errors when switching apps
|
- No more "connection abort" errors when switching apps
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
|
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
|
||||||
- Download queue is now persisted to storage and automatically restored on app restart
|
- Download queue is now persisted to storage and automatically restored on app restart
|
||||||
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
|
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
|
||||||
@@ -212,11 +615,13 @@
|
|||||||
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
|
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
|
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
|
||||||
|
|
||||||
## [1.6.0] - 2026-01-02
|
## [1.6.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Manual Quality Selection**: New option to choose audio quality before each download
|
- **Manual Quality Selection**: New option to choose audio quality before each download
|
||||||
- Toggle "Ask Before Download" in Download Settings
|
- Toggle "Ask Before Download" in Download Settings
|
||||||
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
|
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
|
||||||
@@ -231,12 +636,14 @@
|
|||||||
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
|
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
|
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
|
||||||
- Users on hotfix versions now properly receive update notifications
|
- Users on hotfix versions now properly receive update notifications
|
||||||
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
|
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
|
||||||
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
|
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
|
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
|
||||||
- Items in same group are connected with rounded card container
|
- Items in same group are connected with rounded card container
|
||||||
- Section headers outside cards for clear visual hierarchy
|
- Section headers outside cards for clear visual hierarchy
|
||||||
@@ -245,6 +652,7 @@
|
|||||||
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
|
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
||||||
- **Dependencies Updated**:
|
- **Dependencies Updated**:
|
||||||
- `share_plus`: 10.1.4 → 12.0.1
|
- `share_plus`: 10.1.4 → 12.0.1
|
||||||
@@ -254,6 +662,7 @@
|
|||||||
## [1.5.5] - 2026-01-02
|
## [1.5.5] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
|
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
|
||||||
- Supports track, album, playlist, and artist URLs
|
- Supports track, album, playlist, and artist URLs
|
||||||
- Auto-fetches metadata when link is shared
|
- Auto-fetches metadata when link is shared
|
||||||
@@ -280,6 +689,7 @@
|
|||||||
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
|
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
|
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
|
||||||
- Shows download queue at top when active
|
- Shows download queue at top when active
|
||||||
- Completed downloads auto-move to history section
|
- Completed downloads auto-move to history section
|
||||||
@@ -290,11 +700,13 @@
|
|||||||
- Only shows exit dialog when truly at root
|
- Only shows exit dialog when truly at root
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added)
|
- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added)
|
||||||
- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views
|
- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views
|
||||||
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
|
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search
|
- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search
|
||||||
- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab
|
- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab
|
||||||
- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones
|
- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones
|
||||||
@@ -303,26 +715,31 @@
|
|||||||
## [1.5.0-hotfix6] - 2026-01-02
|
## [1.5.0-hotfix6] - 2026-01-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
|
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
|
||||||
|
|
||||||
## [1.5.0-hotfix5] - 2026-01-02
|
## [1.5.0-hotfix5] - 2026-01-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **App Signing**: Use key.properties as per Flutter official documentation
|
- **App Signing**: Use key.properties as per Flutter official documentation
|
||||||
|
|
||||||
## [1.5.0-hotfix4] - 2026-01-02
|
## [1.5.0-hotfix4] - 2026-01-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **App Signing**: Create keystore.properties in workflow for Gradle
|
- **App Signing**: Create keystore.properties in workflow for Gradle
|
||||||
|
|
||||||
## [1.5.0-hotfix] - 2026-01-02
|
## [1.5.0-hotfix] - 2026-01-02
|
||||||
|
|
||||||
### Important Notice
|
### Important Notice
|
||||||
|
|
||||||
We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
|
We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
|
||||||
|
|
||||||
**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
|
**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **In-App Update**: Download and install updates directly from the app
|
- **In-App Update**: Download and install updates directly from the app
|
||||||
- Progress bar shows download status
|
- Progress bar shows download status
|
||||||
- Automatic device architecture detection (arm64/arm32)
|
- Automatic device architecture detection (arm64/arm32)
|
||||||
@@ -330,11 +747,13 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- **Consistent App Signing**: All future releases will use the same signing key
|
- **Consistent App Signing**: All future releases will use the same signing key
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Update Checker**: Now downloads APK directly instead of opening browser
|
- **Update Checker**: Now downloads APK directly instead of opening browser
|
||||||
|
|
||||||
## [1.5.0] - 2026-01-02
|
## [1.5.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
|
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
|
||||||
- Progress bar in notification during download
|
- Progress bar in notification during download
|
||||||
- Completion notification when track finishes
|
- Completion notification when track finishes
|
||||||
@@ -358,6 +777,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- Downloads correct APK for your device
|
- Downloads correct APK for your device
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
|
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
|
||||||
- **Queue UI Redesign**: Card-based layout with clearer status indicators
|
- **Queue UI Redesign**: Card-based layout with clearer status indicators
|
||||||
- Removed global pause/resume in favor of per-item controls
|
- Removed global pause/resume in favor of per-item controls
|
||||||
@@ -382,6 +802,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- "Add Music" button for quick access
|
- "Add Music" button for quick access
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- Added `flutter_local_notifications` package for notifications
|
- Added `flutter_local_notifications` package for notifications
|
||||||
- Added notification permission request in setup screen for Android 13+
|
- Added notification permission request in setup screen for Android 13+
|
||||||
- Enabled core library desugaring for all Android subprojects
|
- Enabled core library desugaring for all Android subprojects
|
||||||
@@ -390,6 +811,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
|
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
|
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
|
||||||
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
|
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
|
||||||
- Optimized state management: Use `select()` to only rebuild when specific state changes
|
- Optimized state management: Use `select()` to only rebuild when specific state changes
|
||||||
@@ -398,6 +820,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
## [1.2.0] - 2026-01-02
|
## [1.2.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
|
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
|
||||||
- Material Expressive 3 design with cover art header and gradient
|
- Material Expressive 3 design with cover art header and gradient
|
||||||
- Hero animation from list to detail view
|
- Hero animation from list to detail view
|
||||||
@@ -410,12 +833,14 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
|
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
|
- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
|
||||||
- Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
|
- Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
|
||||||
- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
|
- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
|
||||||
- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
|
- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
|
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
|
||||||
- Play button still available for quick playback
|
- Play button still available for quick playback
|
||||||
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
|
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
|
||||||
@@ -424,30 +849,34 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
## [1.1.2] - 2026-01-01
|
## [1.1.2] - 2026-01-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Update Checker**: Automatic check for new versions from GitHub releases
|
- **Update Checker**: Automatic check for new versions from GitHub releases
|
||||||
- Shows changelog in update dialog
|
- Shows changelog in update dialog
|
||||||
- Option to disable update notifications
|
- Option to disable update notifications
|
||||||
- **Release Changelog**: GitHub releases now include full changelog
|
- **Release Changelog**: GitHub releases now include full changelog
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Updated version to 1.1.2
|
- Updated version to 1.1.2
|
||||||
|
|
||||||
## [1.1.1] - 2026-01-01
|
## [1.1.1] - 2026-01-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **About Dialog**: Custom About dialog with cleaner layout
|
- **About Dialog**: Custom About dialog with cleaner layout
|
||||||
- **Setup Screen**: Fixed step indicator line alignment
|
- **Setup Screen**: Fixed step indicator line alignment
|
||||||
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
|
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
|
||||||
- **Copyright Year**: Updated to 2026
|
- **Copyright Year**: Updated to 2026
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Removed Theme Preview from Settings
|
- Removed Theme Preview from Settings
|
||||||
- Added MIT License
|
- Added MIT License
|
||||||
|
|
||||||
|
|
||||||
## [1.1.0] - 2026-01-01
|
## [1.1.0] - 2026-01-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
||||||
- Default: Sequential (1 at a time) for stability
|
- Default: Sequential (1 at a time) for stability
|
||||||
- Options: 1, 2, or 3 concurrent downloads
|
- Options: 1, 2, or 3 concurrent downloads
|
||||||
@@ -458,15 +887,18 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
||||||
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
||||||
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
||||||
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Updated version to 1.1.0
|
- Updated version to 1.1.0
|
||||||
|
|
||||||
### Technical Details
|
### Technical Details
|
||||||
|
|
||||||
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
||||||
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
||||||
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
||||||
@@ -475,6 +907,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- Added `CleanupConnections()` export for Flutter to call via method channel
|
- Added `CleanupConnections()` export for Flutter to call via method channel
|
||||||
|
|
||||||
## [1.0.5] - Previous Release
|
## [1.0.5] - Previous Release
|
||||||
|
|
||||||
- Material Expressive 3 UI
|
- Material Expressive 3 UI
|
||||||
- Dynamic color support
|
- Dynamic color support
|
||||||
- Swipe navigation with PageView
|
- Swipe navigation with PageView
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/cd205e22783a179aab80a2f0cc4445c84e59615a08c11d6e722ab4692c26ac37)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -22,11 +23,30 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## Metadata Source
|
||||||
|
|
||||||
|
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!
|
> **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!
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -7,9 +10,9 @@ plugins {
|
|||||||
|
|
||||||
// Load keystore properties for local builds
|
// Load keystore properties for local builds
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
val keystoreProperties = java.util.Properties()
|
val keystoreProperties = Properties()
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -32,10 +35,10 @@ android {
|
|||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
create("release") {
|
create("release") {
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
storePassword = keystoreProperties["storePassword"] as String
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,7 +46,7 @@ android {
|
|||||||
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
|
||||||
@@ -94,8 +97,10 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
implementation(files("libs/gobackend.aar"))
|
|
||||||
implementation(files("libs/ffmpeg-kit-with-lame.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>;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import androidx.core.app.NotificationCompat
|
|||||||
/**
|
/**
|
||||||
* Foreground service to keep downloads running when app is in background.
|
* Foreground service to keep downloads running when app is in background.
|
||||||
* This prevents Android from killing the download process or throttling network.
|
* 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() {
|
||||||
|
|
||||||
@@ -106,6 +109,19 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"readFileMetadata" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.readFileMetadata(filePath)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"startDownloadService" -> {
|
"startDownloadService" -> {
|
||||||
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") ?: ""
|
||||||
@@ -211,6 +218,105 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
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) {
|
||||||
|
|||||||
|
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 |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -105,6 +105,78 @@ class FFmpegServiceIOS {
|
|||||||
return null;
|
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
|
/// Check if FFmpeg is available
|
||||||
static Future<bool> isAvailable() async {
|
static Future<bool> isAvailable() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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"`
|
||||||
@@ -40,46 +52,46 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
// Check if one contains the other
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Check first artist (before comma or feat)
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
if expectedFirst == foundFirst {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
// Check if first artist is contained in the other
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
// assume they're the same artist with different transliteration
|
// assume they're the same artist with different transliteration
|
||||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
foundASCII := amazonIsASCIIString(foundArtist)
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
if expectedASCII != foundASCII {
|
if expectedASCII != foundASCII {
|
||||||
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +105,57 @@ func amazonIsASCIIString(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
return &AmazonDownloader{
|
amazonDownloaderOnce.Do(func() {
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
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
|
||||||
@@ -113,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
|
||||||
@@ -121,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)
|
||||||
@@ -142,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,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)
|
||||||
@@ -223,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" {
|
||||||
@@ -257,7 +344,6 @@ 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, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
@@ -283,39 +369,70 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
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 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, 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 item progress writer
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
var bytesWritten int64
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(out, itemID)
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
written, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
// Fallback: direct copy without progress tracking
|
||||||
bytesWritten, err = io.Copy(out, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(bytesWritten)/(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
|
// AmazonDownloadResult contains download result with quality info
|
||||||
type AmazonDownloadResult struct {
|
type AmazonDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate 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
|
||||||
@@ -330,7 +447,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// 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 AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
}
|
}
|
||||||
@@ -354,12 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Verify artist matches
|
// Verify artist matches
|
||||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||||
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", 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)
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found
|
// Log match found
|
||||||
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
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{}{
|
||||||
@@ -378,11 +510,29 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file with item ID for progress tracking
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
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)
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
@@ -392,78 +542,109 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// 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")
|
||||||
|
|
||||||
// Read actual quality from the downloaded FLAC file
|
// Read actual quality from the downloaded FLAC file
|
||||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||||
quality, err := GetAudioQuality(outputPath)
|
quality, err := GetAudioQuality(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
// Return 0 to indicate unknown quality
|
} else {
|
||||||
return AmazonDownloadResult{
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
FilePath: outputPath,
|
|
||||||
BitDepth: 0,
|
|
||||||
SampleRate: 0,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[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{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: quality.BitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: quality.SampleRate,
|
SampleRate: sampleRate,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: actualTrackNum,
|
||||||
|
DiscNumber: actualDiscNum,
|
||||||
|
ISRC: req.ISRC,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,709 @@
|
|||||||
|
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) {
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
var trackResp struct {
|
||||||
|
Data []deezerTrack `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
var artistResp struct {
|
||||||
|
Data []deezerArtist `json:"data"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,18 +42,18 @@ func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,18 +62,18 @@ 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
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,18 +82,18 @@ func SearchSpotify(query string, limit int) (string, error) {
|
|||||||
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
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
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ type DownloadRequest struct {
|
|||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,18 +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
|
// Actual quality info from the source
|
||||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
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
|
// DownloadResult is a generic result type for all downloaders
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate 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
|
||||||
@@ -165,25 +181,32 @@ 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())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim whitespace from string fields to prevent filename/path issues
|
// Trim whitespace from string fields to prevent filename/path issues
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
var result DownloadResult
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch req.Service {
|
switch req.Service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
tidalResult, tidalErr := downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
if tidalErr == nil {
|
if tidalErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: tidalResult.FilePath,
|
FilePath: tidalResult.FilePath,
|
||||||
BitDepth: tidalResult.BitDepth,
|
BitDepth: tidalResult.BitDepth,
|
||||||
SampleRate: tidalResult.SampleRate,
|
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
|
err = tidalErr
|
||||||
@@ -191,9 +214,16 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
if qobuzErr == nil {
|
if qobuzErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: qobuzResult.FilePath,
|
FilePath: qobuzResult.FilePath,
|
||||||
BitDepth: qobuzResult.BitDepth,
|
BitDepth: qobuzResult.BitDepth,
|
||||||
SampleRate: qobuzResult.SampleRate,
|
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
|
err = qobuzErr
|
||||||
@@ -201,20 +231,27 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
Artist: amazonResult.Artist,
|
||||||
|
Album: amazonResult.Album,
|
||||||
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
ISRC: amazonResult.ISRC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
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(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
@@ -232,21 +269,28 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: req.Service,
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download complete",
|
Message: "Download complete",
|
||||||
@@ -254,8 +298,15 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: req.Service,
|
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
|
||||||
}
|
}
|
||||||
@@ -267,21 +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
|
// Trim whitespace from string fields to prevent filename/path issues
|
||||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -289,48 +342,78 @@ 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 result DownloadResult
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch service {
|
switch service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
tidalResult, tidalErr := downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
if tidalErr == nil {
|
if tidalErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: tidalResult.FilePath,
|
FilePath: tidalResult.FilePath,
|
||||||
BitDepth: tidalResult.BitDepth,
|
BitDepth: tidalResult.BitDepth,
|
||||||
SampleRate: tidalResult.SampleRate,
|
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
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
if qobuzErr == nil {
|
if qobuzErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: qobuzResult.FilePath,
|
FilePath: qobuzResult.FilePath,
|
||||||
BitDepth: qobuzResult.BitDepth,
|
BitDepth: qobuzResult.BitDepth,
|
||||||
SampleRate: qobuzResult.SampleRate,
|
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
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
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
|
err = amazonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
@@ -349,21 +432,28 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: service,
|
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)
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
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,
|
||||||
@@ -371,14 +461,21 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: service,
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +513,44 @@ func CleanupConnections() {
|
|||||||
CloseIdleConnections()
|
CloseIdleConnections()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadFileMetadata reads metadata directly from a FLAC file
|
||||||
|
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
|
||||||
|
// This is useful for displaying accurate metadata in the UI without relying on cached data
|
||||||
|
func ReadFileMetadata(filePath string) (string, error) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
@@ -424,27 +559,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
|
||||||
}
|
}
|
||||||
@@ -478,7 +633,7 @@ 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
|
||||||
// First tries to extract from file, then falls back to fetching from internet
|
// First tries to extract from file, then falls back to fetching from internet
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
||||||
// Try to extract from file first (much faster)
|
// Try to extract from file first (much faster)
|
||||||
@@ -496,7 +651,8 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyricsData)
|
// Convert to LRC format with metadata headers (like PC version)
|
||||||
|
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,10 +672,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
|
||||||
|
|||||||
@@ -1,36 +1,75 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"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
|
||||||
@@ -43,6 +82,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
|
// Optimized for large file downloads (FLAC ~30-50MB)
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -56,6 +96,9 @@ var sharedTransport = &http.Transport{
|
|||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||||
ForceAttemptHTTP2: true,
|
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)
|
// Shared HTTP client for general requests (reuses connections)
|
||||||
@@ -96,9 +139,15 @@ func 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
|
||||||
@@ -121,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)
|
||||||
@@ -133,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)
|
||||||
}
|
}
|
||||||
@@ -154,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)
|
||||||
}
|
}
|
||||||
@@ -258,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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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\..*?\)`,
|
||||||
|
|||||||
@@ -257,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
|
||||||
}
|
}
|
||||||
@@ -291,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 ""
|
||||||
@@ -382,6 +406,7 @@ type AudioQuality struct {
|
|||||||
|
|
||||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
// 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
|
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||||
|
// For M4A files, it delegates to GetM4AQuality
|
||||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -389,45 +414,401 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Read FLAC marker (4 bytes: "fLaC")
|
// Read first 4 bytes to detect file type
|
||||||
marker := make([]byte, 4)
|
marker := make([]byte, 4)
|
||||||
if _, err := file.Read(marker); err != nil {
|
if _, err := file.Read(marker); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
}
|
}
|
||||||
if string(marker) != "fLaC" {
|
|
||||||
return AudioQuality{}, fmt.Errorf("not a FLAC file")
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
// Read metadata block header (4 bytes)
|
blockType := header[0] & 0x7F
|
||||||
// Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO)
|
if blockType != 0 {
|
||||||
// Bytes 1-3: block length (24-bit big-endian)
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
header := make([]byte, 4)
|
}
|
||||||
if _, err := file.Read(header); err != nil {
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
return AudioQuality{
|
||||||
|
BitDepth: bitsPerSample,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
}, 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)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
blockType := header[0] & 0x7F
|
if string(header8[4:8]) == "ftyp" {
|
||||||
if blockType != 0 {
|
// It's an M4A/MP4 file, use M4A quality reader
|
||||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
file.Close() // Close before calling GetM4AQuality which opens the file again
|
||||||
|
return GetM4AQuality(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read STREAMINFO block (34 bytes minimum)
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
// Bytes 10-13 contain sample rate (20 bits), channels (3 bits), bits per sample (5 bits)
|
}
|
||||||
streamInfo := make([]byte, 34)
|
|
||||||
if _, err := file.Read(streamInfo); err != nil {
|
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
// ========================================
|
||||||
}
|
// M4A (MP4/AAC) Metadata Embedding
|
||||||
|
// ========================================
|
||||||
// Parse sample rate (20 bits starting at byte 10)
|
|
||||||
// Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
// This is a simplified implementation that writes metadata to the file
|
||||||
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
// Parse bits per sample (5 bits)
|
// Read the entire file
|
||||||
// Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1
|
data, err := os.ReadFile(filePath)
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
return AudioQuality{
|
}
|
||||||
BitDepth: bitsPerSample,
|
|
||||||
SampleRate: sampleRate,
|
// Find moov atom position
|
||||||
}, nil
|
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()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress
|
// DownloadProgress represents current download progress
|
||||||
@@ -23,6 +24,7 @@ type ItemProgress struct {
|
|||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||||
|
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
}
|
}
|
||||||
@@ -124,6 +126,20 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// CompleteItemProgress marks an item as complete
|
||||||
func CompleteItemProgress(itemID string) {
|
func CompleteItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
@@ -196,27 +212,55 @@ func getDownloadDir() string {
|
|||||||
|
|
||||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
current int64
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||||
|
|
||||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
|
now := time.Now()
|
||||||
return &ItemProgressWriter{
|
return &ItemProgressWriter{
|
||||||
writer: w,
|
writer: w,
|
||||||
itemID: itemID,
|
itemID: itemID,
|
||||||
current: 0,
|
current: 0,
|
||||||
|
lastReported: 0,
|
||||||
|
startTime: now,
|
||||||
|
lastTime: now,
|
||||||
|
lastBytes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer
|
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
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)
|
||||||
SetItemBytesReceived(pw.itemID, 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
// QobuzDownloader handles Qobuz downloads
|
||||||
@@ -19,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"`
|
||||||
@@ -44,49 +52,224 @@ type QobuzTrack struct {
|
|||||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
// Check if one contains the other
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
// Check first artist (before comma or feat)
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
if expectedFirst == foundFirst {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
// Check if first artist is contained in the other
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||||
// assume they're the same artist with different transliteration
|
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||||
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
||||||
foundASCII := qobuzIsASCIIString(foundArtist)
|
foundLatin := qobuzIsLatinScript(foundArtist)
|
||||||
if expectedASCII != foundASCII {
|
if expectedLatin != foundLatin {
|
||||||
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
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
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
func qobuzIsASCIIString(s string) bool {
|
func qobuzIsASCIIString(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
@@ -97,12 +280,25 @@ func qobuzIsASCIIString(s string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader
|
// containsQueryQobuz checks if a query already exists in the list
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func containsQueryQobuz(queries []string, query string) bool {
|
||||||
return &QobuzDownloader{
|
for _, q := range queries {
|
||||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
if q == query {
|
||||||
appID: "798273057",
|
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
|
||||||
@@ -111,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
|
||||||
@@ -173,6 +369,8 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
|
|
||||||
@@ -200,6 +398,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||||
|
|
||||||
// Find ISRC matches
|
// Find ISRC matches
|
||||||
var isrcMatches []*QobuzTrack
|
var isrcMatches []*QobuzTrack
|
||||||
for i := range result.Tracks.Items {
|
for i := range result.Tracks.Items {
|
||||||
@@ -208,6 +408,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||||
|
|
||||||
if len(isrcMatches) > 0 {
|
if len(isrcMatches) > 0 {
|
||||||
// Verify duration if provided
|
// Verify duration if provided
|
||||||
if expectedDurationSec > 0 {
|
if expectedDurationSec > 0 {
|
||||||
@@ -217,27 +419,27 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
|||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
// Allow 30 seconds tolerance
|
// Allow 10 seconds tolerance
|
||||||
if durationDiff <= 30 {
|
if durationDiff <= 10 {
|
||||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(durationVerifiedMatches) > 0 {
|
if len(durationVerifiedMatches) > 0 {
|
||||||
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||||
return durationVerifiedMatches[0], nil
|
return durationVerifiedMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC matches but duration doesn't
|
// ISRC matches but duration doesn't
|
||||||
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||||
expectedDurationSec, isrcMatches[0].Duration)
|
expectedDurationSec, isrcMatches[0].Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration to verify, return first match
|
// No duration to verify, return first match
|
||||||
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
return isrcMatches[0], nil
|
return isrcMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,10 +461,12 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
// 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) {
|
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
|
||||||
@@ -275,10 +479,54 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
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
|
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 {
|
||||||
@@ -287,6 +535,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +556,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
|
GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
|
||||||
allTracks = append(allTracks, result.Tracks.Items...)
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,16 +565,35 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by title match first (NEW - like Tidal)
|
||||||
|
var titleMatches []*QobuzTrack
|
||||||
|
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 duration verification is requested
|
||||||
if expectedDurationSec > 0 {
|
if expectedDurationSec > 0 {
|
||||||
var durationMatches []*QobuzTrack
|
var durationMatches []*QobuzTrack
|
||||||
for i := range allTracks {
|
for _, track := range tracksToCheck {
|
||||||
track := &allTracks[i]
|
|
||||||
durationDiff := track.Duration - expectedDurationSec
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
if durationDiff <= 30 {
|
if durationDiff <= 10 {
|
||||||
durationMatches = append(durationMatches, track)
|
durationMatches = append(durationMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,24 +602,36 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
// Return best quality among duration matches
|
// Return best quality among duration matches
|
||||||
for _, track := range durationMatches {
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||||
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
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
|
return durationMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration match found
|
// No duration match found
|
||||||
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
|
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration verification, return best quality
|
// No duration verification, return best quality from title matches
|
||||||
for i := range allTracks {
|
for _, track := range tracksToCheck {
|
||||||
track := &allTracks[i]
|
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||||
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &allTracks[0], 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
|
||||||
@@ -369,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 {
|
||||||
@@ -414,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,33 +743,69 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
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 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, 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 item progress writer
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(out, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
// 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
|
// QobuzDownloadResult contains download result with quality info
|
||||||
type QobuzDownloadResult struct {
|
type QobuzDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate 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
|
||||||
@@ -506,23 +823,43 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC with duration verification
|
// OPTIMIZATION: Check cache first for track ID
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
// Verify artist
|
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||||
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||||
req.ArtistName, track.Performer.Name)
|
if err != nil {
|
||||||
track = nil
|
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata with duration verification
|
// 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.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
// Verify artist
|
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
|
||||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, track.Performer.Name)
|
req.ArtistName, track.Performer.Name)
|
||||||
track = nil
|
track = nil
|
||||||
}
|
}
|
||||||
@@ -536,8 +873,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found
|
// Log match found and cache the track ID
|
||||||
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
|
if req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
@@ -568,12 +908,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
case "HI_RES_LOSSLESS":
|
case "HI_RES_LOSSLESS":
|
||||||
qobuzQuality = "27" // 24-bit 192kHz
|
qobuzQuality = "27" // 24-bit 192kHz
|
||||||
}
|
}
|
||||||
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
// Get actual quality from track metadata
|
// Get actual quality from track metadata
|
||||||
actualBitDepth := track.MaximumBitDepth
|
actualBitDepth := track.MaximumBitDepth
|
||||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||||
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
@@ -581,11 +921,29 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file with item ID for progress tracking
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
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)
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
@@ -593,59 +951,62 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata
|
// Embed metadata using parallel-fetched cover data
|
||||||
metadata := Metadata{
|
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
|
||||||
Title: req.TrackName,
|
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
|
||||||
Artist: req.ArtistName,
|
albumName := track.Album.Title
|
||||||
Album: req.AlbumName,
|
if req.AlbumName != "" {
|
||||||
AlbumArtist: req.AlbumArtist,
|
albumName = req.AlbumName
|
||||||
Date: req.ReleaseDate,
|
|
||||||
TrackNumber: req.TrackNumber,
|
|
||||||
TotalTracks: req.TotalTracks,
|
|
||||||
DiscNumber: req.DiscNumber,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download cover to memory (avoids file permission issues on Android)
|
metadata := Metadata{
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: track.Performer.Name,
|
||||||
|
Album: albumName,
|
||||||
|
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||||
|
Date: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
TotalTracks: req.TotalTracks,
|
||||||
|
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||||
|
ISRC: track.ISRC,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
return QobuzDownloadResult{
|
return QobuzDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: actualBitDepth,
|
BitDepth: actualBitDepth,
|
||||||
SampleRate: actualSampleRate,
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hiragana to Romaji mapping
|
||||||
|
var hiraganaToRomaji = map[rune]string{
|
||||||
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
|
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||||
|
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||||
|
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||||
|
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||||
|
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||||
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
|
'っ': "", // Double consonant marker
|
||||||
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Katakana to Romaji mapping
|
||||||
|
var katakanaToRomaji = map[rune]string{
|
||||||
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
|
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||||
|
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||||
|
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||||
|
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||||
|
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||||
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
|
'ッ': "", // Double consonant marker
|
||||||
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
|
// Extended katakana
|
||||||
|
'ー': "", // Long vowel mark
|
||||||
|
'ヴ': "vu",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combination mappings for きゃ, しゃ, etc.
|
||||||
|
var combinationHiragana = map[string]string{
|
||||||
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
|
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||||
|
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
||||||
|
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
||||||
|
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
||||||
|
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
||||||
|
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
||||||
|
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
||||||
|
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
||||||
|
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinationKatakana = map[string]string{
|
||||||
|
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||||
|
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||||
|
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||||
|
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
||||||
|
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
||||||
|
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
||||||
|
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
||||||
|
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
||||||
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
|
// Extended combinations
|
||||||
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsJapanese checks if a string contains Japanese characters
|
||||||
|
func ContainsJapanese(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHiragana(r rune) bool {
|
||||||
|
return r >= 0x3040 && r <= 0x309F
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKatakana(r rune) bool {
|
||||||
|
return r >= 0x30A0 && r <= 0x30FF
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKanji(r rune) bool {
|
||||||
|
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||||
|
}
|
||||||
|
|
||||||
|
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||||
|
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||||
|
func JapaneseToRomaji(text string) string {
|
||||||
|
if !ContainsJapanese(text) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
runes := []rune(text)
|
||||||
|
i := 0
|
||||||
|
|
||||||
|
for i < len(runes) {
|
||||||
|
// 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
|
||||||
|
if i < len(runes)-1 {
|
||||||
|
combo := string(runes[i : i+2])
|
||||||
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if romaji, ok := combinationKatakana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single character conversion
|
||||||
|
r := runes[i]
|
||||||
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if isKanji(r) {
|
||||||
|
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
// Keep other characters (punctuation, spaces, etc.)
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSearchQuery creates a search query from track name and artist
|
||||||
|
// Converts Japanese to romaji if present
|
||||||
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
|
// Convert Japanese to romaji
|
||||||
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
|
// Clean up the query - remove special characters that might interfere with search
|
||||||
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -495,6 +495,17 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
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"`
|
||||||
@@ -502,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,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,
|
||||||
@@ -566,6 +598,47 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
||||||
|
// Similar to Deezer implementation for consistency
|
||||||
|
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||||
|
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
|
// First request to get playlist info and first batch of tracks
|
||||||
var data struct {
|
var data struct {
|
||||||
@@ -620,11 +693,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch remaining tracks using pagination (up to 1000 tracks max)
|
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||||
nextURL := data.Tracks.Next
|
nextURL := data.Tracks.Next
|
||||||
maxTracks := 1000
|
|
||||||
|
|
||||||
for nextURL != "" && len(tracks) < maxTracks {
|
for nextURL != "" {
|
||||||
var pageData struct {
|
var pageData struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
Track *trackFull `json:"track"`
|
Track *trackFull `json:"track"`
|
||||||
@@ -642,9 +714,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(tracks) >= maxTracks {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.Track.ID,
|
SpotifyID: item.Track.ID,
|
||||||
Artists: joinArtists(item.Track.Artists),
|
Artists: joinArtists(item.Track.Artists),
|
||||||
@@ -835,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)
|
||||||
}
|
}
|
||||||
@@ -863,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++";
|
||||||
|
|||||||
@@ -181,6 +181,106 @@ import Gobackend // Import Go framework
|
|||||||
GobackendCleanupConnections()
|
GobackendCleanupConnections()
|
||||||
return nil
|
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 |
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.0.7-preview';
|
static const String version = '2.2.5';
|
||||||
static const String buildNumber = '37';
|
static const String buildNumber = '47';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
@@ -15,4 +15,6 @@ class AppInfo {
|
|||||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ enum DownloadStatus {
|
|||||||
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;
|
||||||
@@ -20,8 +28,10 @@ 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
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
|
||||||
@@ -31,8 +41,10 @@ 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,
|
this.qualityOverride,
|
||||||
});
|
});
|
||||||
@@ -43,8 +55,10 @@ 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,
|
String? qualityOverride,
|
||||||
}) {
|
}) {
|
||||||
@@ -54,13 +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,
|
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);
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
||||||
DownloadStatus.queued,
|
DownloadStatus.queued,
|
||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
qualityOverride: json['qualityOverride'] as String?,
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
);
|
);
|
||||||
@@ -27,8 +29,10 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'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,
|
'qualityOverride': instance.qualityOverride,
|
||||||
};
|
};
|
||||||
@@ -41,3 +45,10 @@ const _$DownloadStatusEnumMap = {
|
|||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
DownloadStatus.skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _$DownloadErrorTypeEnumMap = {
|
||||||
|
DownloadErrorType.unknown: 'unknown',
|
||||||
|
DownloadErrorType.notFound: 'notFound',
|
||||||
|
DownloadErrorType.rateLimit: 'rateLimit',
|
||||||
|
DownloadErrorType.network: 'network',
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class AppSettings {
|
|||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
final int concurrentDownloads; // 1 = sequential (default), max 3
|
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||||
final bool checkForUpdates; // Check for updates on app start
|
final bool checkForUpdates; // Check for updates on app start
|
||||||
|
final String updateChannel; // stable, preview
|
||||||
final bool hasSearchedBefore; // Hide helper text after first search
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
final String folderOrganization; // none, artist, album, artist_album
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
final String historyViewMode; // list, grid
|
final String historyViewMode; // list, grid
|
||||||
@@ -21,6 +22,8 @@ class AppSettings {
|
|||||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
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',
|
||||||
@@ -33,6 +36,7 @@ class AppSettings {
|
|||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
this.concurrentDownloads = 1, // Default: sequential (off)
|
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||||
this.checkForUpdates = true, // Default: enabled
|
this.checkForUpdates = true, // Default: enabled
|
||||||
|
this.updateChannel = 'stable', // Default: stable releases only
|
||||||
this.hasSearchedBefore = false, // Default: show helper text
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
this.folderOrganization = 'none', // Default: no folder organization
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
this.historyViewMode = 'grid', // Default: grid view
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
@@ -40,6 +44,8 @@ class AppSettings {
|
|||||||
this.spotifyClientId = '', // Default: use built-in credentials
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
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({
|
||||||
@@ -53,6 +59,7 @@ class AppSettings {
|
|||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
int? concurrentDownloads,
|
int? concurrentDownloads,
|
||||||
bool? checkForUpdates,
|
bool? checkForUpdates,
|
||||||
|
String? updateChannel,
|
||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
String? historyViewMode,
|
String? historyViewMode,
|
||||||
@@ -60,6 +67,8 @@ class AppSettings {
|
|||||||
String? spotifyClientId,
|
String? spotifyClientId,
|
||||||
String? spotifyClientSecret,
|
String? spotifyClientSecret,
|
||||||
bool? useCustomSpotifyCredentials,
|
bool? useCustomSpotifyCredentials,
|
||||||
|
String? metadataSource,
|
||||||
|
bool? enableLogging,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -72,6 +81,7 @@ class AppSettings {
|
|||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||||
|
updateChannel: updateChannel ?? this.updateChannel,
|
||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
@@ -79,6 +89,8 @@ class AppSettings {
|
|||||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
|
enableLogging: enableLogging ?? this.enableLogging,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
|
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
@@ -25,6 +26,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
useCustomSpotifyCredentials:
|
useCustomSpotifyCredentials:
|
||||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
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) =>
|
||||||
@@ -39,6 +42,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
'concurrentDownloads': instance.concurrentDownloads,
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
'checkForUpdates': instance.checkForUpdates,
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
|
'updateChannel': instance.updateChannel,
|
||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
'historyViewMode': instance.historyViewMode,
|
'historyViewMode': instance.historyViewMode,
|
||||||
@@ -46,4 +50,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'spotifyClientId': instance.spotifyClientId,
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
'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) =>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
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?,
|
||||||
|
deezerId: json['deezerId'] as String?,
|
||||||
availability: json['availability'] == null
|
availability: json['availability'] == null
|
||||||
? null
|
? null
|
||||||
: ServiceAvailability.fromJson(
|
: ServiceAvailability.fromJson(
|
||||||
@@ -37,6 +38,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'trackNumber': instance.trackNumber,
|
'trackNumber': instance.trackNumber,
|
||||||
'discNumber': instance.discNumber,
|
'discNumber': instance.discNumber,
|
||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,9 +47,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
|||||||
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(
|
||||||
@@ -56,7 +61,10 @@ Map<String, dynamic> _$ServiceAvailabilityToJson(
|
|||||||
'tidal': instance.tidal,
|
'tidal': instance.tidal,
|
||||||
'qobuz': instance.qobuz,
|
'qobuz': instance.qobuz,
|
||||||
'amazon': instance.amazon,
|
'amazon': instance.amazon,
|
||||||
|
'deezer': instance.deezer,
|
||||||
'tidalUrl': instance.tidalUrl,
|
'tidalUrl': instance.tidalUrl,
|
||||||
'qobuzUrl': instance.qobuzUrl,
|
'qobuzUrl': instance.qobuzUrl,
|
||||||
'amazonUrl': instance.amazonUrl,
|
'amazonUrl': instance.amazonUrl,
|
||||||
|
'deezerUrl': instance.deezerUrl,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -35,6 +36,9 @@ class DownloadHistoryItem {
|
|||||||
final int? duration;
|
final int? duration;
|
||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
final String? quality;
|
final String? quality;
|
||||||
|
// Audio quality info (from file after download)
|
||||||
|
final int? bitDepth;
|
||||||
|
final int? sampleRate;
|
||||||
|
|
||||||
const DownloadHistoryItem({
|
const DownloadHistoryItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -53,6 +57,8 @@ class DownloadHistoryItem {
|
|||||||
this.duration,
|
this.duration,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
this.quality,
|
this.quality,
|
||||||
|
this.bitDepth,
|
||||||
|
this.sampleRate,
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -72,6 +78,8 @@ class DownloadHistoryItem {
|
|||||||
'duration': duration,
|
'duration': duration,
|
||||||
'releaseDate': releaseDate,
|
'releaseDate': releaseDate,
|
||||||
'quality': quality,
|
'quality': quality,
|
||||||
|
'bitDepth': bitDepth,
|
||||||
|
'sampleRate': sampleRate,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
|
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
|
||||||
@@ -91,6 +99,8 @@ class DownloadHistoryItem {
|
|||||||
duration: json['duration'] as int?,
|
duration: json['duration'] as int?,
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
quality: json['quality'] as String?,
|
quality: json['quality'] as String?,
|
||||||
|
bitDepth: json['bitDepth'] as int?,
|
||||||
|
sampleRate: json['sampleRate'] as int?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +381,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final itemProgress = entry.value as Map<String, dynamic>;
|
final itemProgress = entry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||||
|
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
|
||||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
||||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||||
|
|
||||||
@@ -389,14 +400,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDownloading && bytesTotal > 0) {
|
// Use progress from backend if available (handles both explicit progress and byte-based)
|
||||||
final percentage = bytesReceived / bytesTotal;
|
final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
updateProgress(itemId, percentage);
|
|
||||||
|
if (isDownloading) {
|
||||||
|
double percentage = 0.0;
|
||||||
|
if (bytesTotal > 0) {
|
||||||
|
// Calculate from bytes if available for precision
|
||||||
|
percentage = bytesReceived / bytesTotal;
|
||||||
|
} else {
|
||||||
|
// Fallback to backend-reported progress (e.g. for DASH segments)
|
||||||
|
percentage = progressFromBackend;
|
||||||
|
}
|
||||||
|
|
||||||
// Log progress for each item
|
updateProgress(itemId, percentage, speedMBps: speedMBps);
|
||||||
|
|
||||||
|
// Log progress for each item with speed
|
||||||
final mbReceived = bytesReceived / (1024 * 1024);
|
final mbReceived = bytesReceived / (1024 * 1024);
|
||||||
final mbTotal = bytesTotal / (1024 * 1024);
|
final mbTotal = bytesTotal / (1024 * 1024);
|
||||||
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
if (bytesTotal > 0) {
|
||||||
|
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s');
|
||||||
|
} else {
|
||||||
|
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,11 +453,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
? downloadingItems.first.track.artistName
|
? downloadingItems.first.track.artistName
|
||||||
: 'Downloading...';
|
: 'Downloading...';
|
||||||
|
|
||||||
|
// Calculate notification progress values
|
||||||
|
int notifProgress = bytesReceived;
|
||||||
|
int notifTotal = bytesTotal;
|
||||||
|
|
||||||
|
if (bytesTotal <= 0) {
|
||||||
|
// Fallback to percentage for DASH/unknown size
|
||||||
|
final progressPercent = (firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
notifProgress = (progressPercent * 100).toInt();
|
||||||
|
notifTotal = 100;
|
||||||
|
}
|
||||||
|
|
||||||
_notificationService.showDownloadProgress(
|
_notificationService.showDownloadProgress(
|
||||||
trackName: trackName,
|
trackName: trackName,
|
||||||
artistName: artistName,
|
artistName: artistName,
|
||||||
progress: bytesReceived,
|
progress: notifProgress,
|
||||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
total: notifTotal > 0 ? notifTotal : 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update foreground service notification (Android)
|
// Update foreground service notification (Android)
|
||||||
@@ -439,8 +476,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
PlatformBridge.updateDownloadServiceProgress(
|
PlatformBridge.updateDownloadServiceProgress(
|
||||||
trackName: downloadingItems.first.track.name,
|
trackName: downloadingItems.first.track.name,
|
||||||
artistName: downloadingItems.first.track.artistName,
|
artistName: downloadingItems.first.track.artistName,
|
||||||
progress: bytesReceived,
|
progress: notifProgress,
|
||||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
total: notifTotal > 0 ? notifTotal : 1,
|
||||||
queueCount: state.queuedCount,
|
queueCount: state.queuedCount,
|
||||||
).catchError((_) {}); // Ignore errors
|
).catchError((_) {}); // Ignore errors
|
||||||
}
|
}
|
||||||
@@ -609,14 +646,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateItemStatus(String id, DownloadStatus status, {double? progress, String? filePath, String? error}) {
|
void updateItemStatus(String id, DownloadStatus status, {double? progress, double? speedMBps, String? filePath, String? error, DownloadErrorType? errorType}) {
|
||||||
final items = state.items.map((item) {
|
final items = state.items.map((item) {
|
||||||
if (item.id == id) {
|
if (item.id == id) {
|
||||||
return item.copyWith(
|
return item.copyWith(
|
||||||
status: status,
|
status: status,
|
||||||
progress: progress ?? item.progress,
|
progress: progress ?? item.progress,
|
||||||
|
speedMBps: speedMBps ?? item.speedMBps,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
error: error,
|
error: error,
|
||||||
|
errorType: errorType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
@@ -632,8 +671,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateProgress(String id, double progress) {
|
void updateProgress(String id, double progress, {double? speedMBps}) {
|
||||||
updateItemStatus(id, DownloadStatus.downloading, progress: progress);
|
updateItemStatus(id, DownloadStatus.downloading, progress: progress, speedMBps: speedMBps);
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelItem(String id) {
|
void cancelItem(String id) {
|
||||||
@@ -686,20 +725,37 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retry a failed download
|
/// Retry a failed or skipped download
|
||||||
void retryItem(String id) {
|
void retryItem(String id) {
|
||||||
final items = state.items.map((item) {
|
final item = state.items.where((i) => i.id == id).firstOrNull;
|
||||||
if (item.id == id && item.status == DownloadStatus.failed) {
|
if (item == null) {
|
||||||
return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
|
_log.w('retryItem: Item not found: $id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only retry if status is failed or skipped
|
||||||
|
if (item.status != DownloadStatus.failed && item.status != DownloadStatus.skipped) {
|
||||||
|
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
||||||
|
|
||||||
|
final items = state.items.map((i) {
|
||||||
|
if (i.id == id) {
|
||||||
|
return i.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
|
||||||
}
|
}
|
||||||
return item;
|
return i;
|
||||||
}).toList();
|
}).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_saveQueueToStorage(); // Persist queue
|
_saveQueueToStorage(); // Persist queue
|
||||||
|
|
||||||
// Start processing if not already
|
// Start processing if not already running
|
||||||
if (!state.isProcessing) {
|
if (!state.isProcessing) {
|
||||||
|
_log.d('Starting queue processing for retry');
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
|
} else {
|
||||||
|
_log.d('Queue already processing, item will be picked up');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,19 +770,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||||
// Download cover first
|
// Download cover first
|
||||||
String? coverPath;
|
String? coverPath;
|
||||||
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
|
final coverUrl = track.coverUrl;
|
||||||
coverPath = '$flacPath.cover.jpg';
|
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||||
|
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
|
||||||
|
|
||||||
// Download cover using HTTP
|
// Download cover using HTTP
|
||||||
final httpClient = HttpClient();
|
final httpClient = HttpClient();
|
||||||
final request = await httpClient.getUrl(Uri.parse(track.coverUrl!));
|
final request = await httpClient.getUrl(Uri.parse(coverUrl));
|
||||||
final response = await request.close();
|
final response = await request.close();
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final file = File(coverPath);
|
final file = File(coverPath);
|
||||||
final sink = file.openWrite();
|
final sink = file.openWrite();
|
||||||
await response.pipe(sink);
|
await response.pipe(sink);
|
||||||
await sink.close();
|
await sink.close();
|
||||||
_log.d('Cover downloaded to: $coverPath');
|
_log.d('Cover downloaded to temp: $coverPath');
|
||||||
} else {
|
} else {
|
||||||
_log.w('Failed to download cover: HTTP ${response.statusCode}');
|
_log.w('Failed to download cover: HTTP ${response.statusCode}');
|
||||||
coverPath = null;
|
coverPath = null;
|
||||||
@@ -740,20 +800,87 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
// Use Go backend to embed metadata
|
// Use Go backend to embed metadata
|
||||||
try {
|
try {
|
||||||
// For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
|
// Use FFmpeg to embed cover art AND text metadata
|
||||||
// FFmpeg can embed cover art to FLAC
|
// FFmpeg can embed cover art to FLAC and also set tags
|
||||||
if (coverPath != null && await File(coverPath).exists()) {
|
|
||||||
final result = await FFmpegService.embedCover(flacPath, coverPath);
|
// Construct metadata map
|
||||||
|
final metadata = <String, String>{
|
||||||
|
'TITLE': track.name,
|
||||||
|
'ARTIST': track.artistName,
|
||||||
|
'ALBUM': track.albumName,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (track.albumArtist != null) {
|
||||||
|
metadata['ALBUMARTIST'] = track.albumArtist!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.trackNumber != null) {
|
||||||
|
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||||
|
metadata['TRACK'] = track.trackNumber.toString(); // Compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.discNumber != null) {
|
||||||
|
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||||
|
metadata['DISC'] = track.discNumber.toString(); // Compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.releaseDate != null) {
|
||||||
|
metadata['DATE'] = track.releaseDate!;
|
||||||
|
metadata['YEAR'] = track.releaseDate!.split('-').first;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.isrc != null) {
|
||||||
|
metadata['ISRC'] = track.isrc!;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.d('Metadata map content: $metadata');
|
||||||
|
|
||||||
|
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
|
||||||
|
// Since we are in the Flutter context, we can call the bridge to get lyrics
|
||||||
|
// This ensures even converted files have lyrics embedded if available
|
||||||
|
try {
|
||||||
|
final lrcContent = await PlatformBridge.getLyricsLRC(
|
||||||
|
track.id, // spotifyID
|
||||||
|
track.name,
|
||||||
|
track.artistName,
|
||||||
|
filePath: '', // No local file path yet (processed in memory)
|
||||||
|
);
|
||||||
|
|
||||||
if (result != null) {
|
if (lrcContent.isNotEmpty) {
|
||||||
_log.d('Cover embedded via FFmpeg');
|
metadata['LYRICS'] = lrcContent;
|
||||||
} else {
|
metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players
|
||||||
_log.w('FFmpeg cover embed failed');
|
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
// Clean up cover file
|
_log.w('Failed to fetch lyrics for embedding: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.d('Generating tags for FLAC: $metadata');
|
||||||
|
|
||||||
|
// Perform embedding (cover + text metadata)
|
||||||
|
// Note: FFmpegService.embedMetadata handles safe temp file creation
|
||||||
|
final result = await FFmpegService.embedMetadata(
|
||||||
|
flacPath: flacPath,
|
||||||
|
coverPath: coverPath != null && await File(coverPath).exists() ? coverPath : null,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
_log.d('Metadata and cover embedded via FFmpeg');
|
||||||
|
} else {
|
||||||
|
_log.w('FFmpeg metadata/cover embed failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cover file if it exists
|
||||||
|
if (coverPath != null) {
|
||||||
try {
|
try {
|
||||||
await File(coverPath).delete();
|
final coverFile = File(coverPath);
|
||||||
|
if (await coverFile.exists()) {
|
||||||
|
// In Android 10+ scoped storage, we can't easily delete if we didn't create it
|
||||||
|
// in this session or if it's not in our app dir.
|
||||||
|
// But coverPath is typically in temp dir now.
|
||||||
|
await coverFile.delete();
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -851,6 +978,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
_log.i('Queue processing finished');
|
_log.i('Queue processing finished');
|
||||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||||
|
|
||||||
|
// Check if there are new queued items (e.g., from retry) and restart if needed
|
||||||
|
final hasQueuedItems = state.items.any((item) => item.status == DownloadStatus.queued);
|
||||||
|
if (hasQueuedItems) {
|
||||||
|
_log.i('Found queued items after processing finished, restarting queue...');
|
||||||
|
Future.microtask(() => _processQueue());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sequential download processing (uses multi-progress system with single item)
|
/// Sequential download processing (uses multi-progress system with single item)
|
||||||
@@ -866,7 +1000,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final nextItem = state.items.firstWhere(
|
// Re-read state to get latest items (important for retry)
|
||||||
|
final currentItems = state.items;
|
||||||
|
final nextItem = currentItems.firstWhere(
|
||||||
(item) => item.status == DownloadStatus.queued,
|
(item) => item.status == DownloadStatus.queued,
|
||||||
orElse: () => DownloadItem(
|
orElse: () => DownloadItem(
|
||||||
id: '',
|
id: '',
|
||||||
@@ -877,10 +1013,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (nextItem.id.isEmpty) {
|
if (nextItem.id.isEmpty) {
|
||||||
_log.d('No more items to process');
|
_log.d('No more items to process (checked ${currentItems.length} items)');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})');
|
||||||
await _downloadSingleItem(nextItem);
|
await _downloadSingleItem(nextItem);
|
||||||
|
|
||||||
// Clear item progress after download completes
|
// Clear item progress after download completes
|
||||||
@@ -965,7 +1102,64 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
try {
|
try {
|
||||||
// Get folder organization setting and build output directory
|
// Get folder organization setting and build output directory
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final outputDir = await _buildOutputDir(item.track, settings.folderOrganization);
|
|
||||||
|
// Metadata Enrichment:
|
||||||
|
// If track number is missing/0 (common from Search results), fetch full metadata
|
||||||
|
// This ensures the downloaded file has correct tags (Track, Disc, Year)
|
||||||
|
Track trackToDownload = item.track;
|
||||||
|
// Enrich metadata if ISRC or track number is missing (common from Search results)
|
||||||
|
// ISRC is critical for accurate track matching on streaming services
|
||||||
|
final needsEnrichment = trackToDownload.id.startsWith('deezer:') &&
|
||||||
|
(trackToDownload.isrc == null || trackToDownload.isrc!.isEmpty ||
|
||||||
|
trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0);
|
||||||
|
|
||||||
|
if (needsEnrichment) {
|
||||||
|
try {
|
||||||
|
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
|
||||||
|
_log.d('Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}');
|
||||||
|
final rawId = trackToDownload.id.split(':')[1];
|
||||||
|
_log.d('Fetching full metadata for Deezer ID: $rawId');
|
||||||
|
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
|
||||||
|
_log.d('Got response keys: ${fullData.keys.toList()}');
|
||||||
|
|
||||||
|
if (fullData.containsKey('track')) {
|
||||||
|
// Parse Go backend response (snake_case) to Track
|
||||||
|
final trackData = fullData['track'];
|
||||||
|
_log.d('Track data type: ${trackData.runtimeType}');
|
||||||
|
if (trackData is Map<String, dynamic>) {
|
||||||
|
final data = trackData;
|
||||||
|
_log.d('Track data keys: ${data.keys.toList()}');
|
||||||
|
_log.d('ISRC from API: ${data['isrc']}');
|
||||||
|
trackToDownload = Track(
|
||||||
|
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
||||||
|
name: (data['name'] as String?) ?? trackToDownload.name,
|
||||||
|
artistName: (data['artists'] as String?) ?? trackToDownload.artistName,
|
||||||
|
albumName: (data['album_name'] as String?) ?? trackToDownload.albumName,
|
||||||
|
albumArtist: data['album_artist'] as String?,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
// duration_ms from Go is in milliseconds, Track.duration is in seconds
|
||||||
|
duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ 1000,
|
||||||
|
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
|
||||||
|
trackNumber: data['track_number'] as int?,
|
||||||
|
discNumber: data['disc_number'] as int?,
|
||||||
|
releaseDate: data['release_date'] as String?,
|
||||||
|
deezerId: rawId,
|
||||||
|
availability: trackToDownload.availability,
|
||||||
|
);
|
||||||
|
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}');
|
||||||
|
} else {
|
||||||
|
_log.w('Unexpected track data type: ${trackData.runtimeType}');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.w('Response does not contain track key');
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log.w('Failed to enrich metadata: $e');
|
||||||
|
_log.w('Stack trace: $stack');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final outputDir = await _buildOutputDir(trackToDownload, settings.folderOrganization);
|
||||||
|
|
||||||
// Use quality override if set, otherwise use default from settings
|
// Use quality override if set, otherwise use default from settings
|
||||||
final quality = item.qualityOverride ?? state.audioQuality;
|
final quality = item.qualityOverride ?? state.audioQuality;
|
||||||
@@ -977,41 +1171,41 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
|
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
|
||||||
_log.d('Output dir: $outputDir');
|
_log.d('Output dir: $outputDir');
|
||||||
result = await PlatformBridge.downloadWithFallback(
|
result = await PlatformBridge.downloadWithFallback(
|
||||||
isrc: item.track.isrc ?? '',
|
isrc: trackToDownload.isrc ?? '',
|
||||||
spotifyId: item.track.id,
|
spotifyId: trackToDownload.id,
|
||||||
trackName: item.track.name,
|
trackName: trackToDownload.name,
|
||||||
artistName: item.track.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: item.track.albumName,
|
albumName: trackToDownload.albumName,
|
||||||
albumArtist: item.track.albumArtist,
|
albumArtist: trackToDownload.albumArtist,
|
||||||
coverUrl: item.track.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
trackNumber: item.track.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
preferredService: item.service,
|
preferredService: item.service,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
durationMs: item.track.duration, // Duration in ms for verification
|
durationMs: trackToDownload.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await PlatformBridge.downloadTrack(
|
result = await PlatformBridge.downloadTrack(
|
||||||
isrc: item.track.isrc ?? '',
|
isrc: trackToDownload.isrc ?? '',
|
||||||
service: item.service,
|
service: item.service,
|
||||||
spotifyId: item.track.id,
|
spotifyId: trackToDownload.id,
|
||||||
trackName: item.track.name,
|
trackName: trackToDownload.name,
|
||||||
artistName: item.track.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: item.track.albumName,
|
albumName: trackToDownload.albumName,
|
||||||
albumArtist: item.track.albumArtist,
|
albumArtist: trackToDownload.albumArtist,
|
||||||
coverUrl: item.track.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
trackNumber: item.track.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
durationMs: item.track.duration, // Duration in ms for verification
|
durationMs: trackToDownload.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,26 +1249,81 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.i('Actual quality: $actualQuality');
|
_log.i('Actual quality: $actualQuality');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
|
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||||
|
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||||
_log.d('Converting M4A to FLAC...');
|
_log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...');
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
|
||||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
try {
|
||||||
if (flacPath != null) {
|
final file = File(filePath);
|
||||||
filePath = flacPath;
|
if (!await file.exists()) {
|
||||||
_log.d('Converted to: $flacPath');
|
_log.e('File does not exist at path: $filePath');
|
||||||
|
} else {
|
||||||
// After conversion, embed metadata and cover to the new FLAC file
|
final length = await file.length();
|
||||||
_log.d('Embedding metadata and cover to converted FLAC...');
|
_log.i('File size before conversion: ${length / 1024} KB');
|
||||||
try {
|
|
||||||
await _embedMetadataAndCover(
|
if (length < 1024) {
|
||||||
flacPath,
|
_log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.');
|
||||||
item.track,
|
} else {
|
||||||
);
|
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95);
|
||||||
_log.d('Metadata and cover embedded successfully');
|
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
||||||
} catch (e) {
|
|
||||||
_log.w('Warning: Failed to embed metadata/cover: $e');
|
if (flacPath != null) {
|
||||||
|
filePath = flacPath;
|
||||||
|
_log.d('Converted to FLAC: $flacPath');
|
||||||
|
|
||||||
|
// After conversion, embed metadata and cover to the new FLAC file
|
||||||
|
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||||
|
try {
|
||||||
|
// Update track with actual metadata from backend result (if available)
|
||||||
|
// This creates the most accurate metadata possible (from the service itself)
|
||||||
|
Track finalTrack = trackToDownload;
|
||||||
|
if (result.containsKey('track_number') || result.containsKey('release_date')) {
|
||||||
|
_log.d('Using metadata from backend response for embedding');
|
||||||
|
final backendTrackNum = result['track_number'] as int?;
|
||||||
|
final backendDiscNum = result['disc_number'] as int?;
|
||||||
|
final backendYear = result['release_date'] as String?;
|
||||||
|
final backendAlbum = result['album'] as String?;
|
||||||
|
|
||||||
|
_log.d('Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear');
|
||||||
|
|
||||||
|
// Create updated track object with safety check for 0/null
|
||||||
|
final newTrackNumber = (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber;
|
||||||
|
final newDiscNumber = (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber;
|
||||||
|
|
||||||
|
_log.d('Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber');
|
||||||
|
|
||||||
|
finalTrack = Track(
|
||||||
|
id: trackToDownload.id,
|
||||||
|
name: trackToDownload.name,
|
||||||
|
artistName: trackToDownload.artistName,
|
||||||
|
albumName: backendAlbum ?? trackToDownload.albumName,
|
||||||
|
albumArtist: trackToDownload.albumArtist,
|
||||||
|
coverUrl: trackToDownload.coverUrl,
|
||||||
|
duration: trackToDownload.duration,
|
||||||
|
isrc: trackToDownload.isrc,
|
||||||
|
trackNumber: newTrackNumber,
|
||||||
|
discNumber: newDiscNumber,
|
||||||
|
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
||||||
|
deezerId: trackToDownload.deezerId,
|
||||||
|
availability: trackToDownload.availability,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use enriched/updated track for metadata embedding
|
||||||
|
await _embedMetadataAndCover(flacPath, finalTrack);
|
||||||
|
_log.d('Metadata and cover embedded successfully');
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.w('FFmpeg conversion returned null, keeping M4A file');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||||
|
// Keep the M4A file if conversion fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,25 +1365,38 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
|
// Extract updated metadata from backend result if available
|
||||||
|
final backendTitle = result['title'] as String?;
|
||||||
|
final backendArtist = result['artist'] as String?;
|
||||||
|
final backendAlbum = result['album'] as String?;
|
||||||
|
final backendYear = result['release_date'] as String?;
|
||||||
|
final backendTrackNum = result['track_number'] as int?;
|
||||||
|
final backendDiscNum = result['disc_number'] as int?;
|
||||||
|
final backendBitDepth = result['actual_bit_depth'] as int?;
|
||||||
|
final backendSampleRate = result['actual_sample_rate'] as int?;
|
||||||
|
final backendISRC = result['isrc'] as String?;
|
||||||
|
|
||||||
ref.read(downloadHistoryProvider.notifier).addToHistory(
|
ref.read(downloadHistoryProvider.notifier).addToHistory(
|
||||||
DownloadHistoryItem(
|
DownloadHistoryItem(
|
||||||
id: item.id,
|
id: item.id,
|
||||||
trackName: item.track.name,
|
trackName: (backendTitle != null && backendTitle.isNotEmpty) ? backendTitle : item.track.name,
|
||||||
artistName: item.track.artistName,
|
artistName: (backendArtist != null && backendArtist.isNotEmpty) ? backendArtist : item.track.artistName,
|
||||||
albumName: item.track.albumName,
|
albumName: (backendAlbum != null && backendAlbum.isNotEmpty) ? backendAlbum : item.track.albumName,
|
||||||
albumArtist: item.track.albumArtist,
|
albumArtist: item.track.albumArtist,
|
||||||
coverUrl: item.track.coverUrl,
|
coverUrl: item.track.coverUrl,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
service: result['service'] as String? ?? item.service,
|
service: result['service'] as String? ?? item.service,
|
||||||
downloadedAt: DateTime.now(),
|
downloadedAt: DateTime.now(),
|
||||||
// Additional metadata
|
// Additional metadata
|
||||||
isrc: item.track.isrc,
|
isrc: (backendISRC != null && backendISRC.isNotEmpty) ? backendISRC : item.track.isrc,
|
||||||
spotifyId: item.track.id,
|
spotifyId: item.track.id,
|
||||||
trackNumber: item.track.trackNumber,
|
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
|
||||||
discNumber: item.track.discNumber,
|
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
|
||||||
duration: item.track.duration,
|
duration: item.track.duration,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
|
||||||
quality: actualQuality,
|
quality: actualQuality,
|
||||||
|
bitDepth: backendBitDepth,
|
||||||
|
sampleRate: backendSampleRate,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1143,11 +1405,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
final errorMsg = result['error'] as String? ?? 'Download failed';
|
||||||
_log.e('Download failed: $errorMsg');
|
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
||||||
|
|
||||||
|
// Convert error type string to enum
|
||||||
|
DownloadErrorType errorType;
|
||||||
|
switch (errorTypeStr) {
|
||||||
|
case 'not_found':
|
||||||
|
errorType = DownloadErrorType.notFound;
|
||||||
|
break;
|
||||||
|
case 'rate_limit':
|
||||||
|
errorType = DownloadErrorType.rateLimit;
|
||||||
|
break;
|
||||||
|
case 'network':
|
||||||
|
errorType = DownloadErrorType.network;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorType = DownloadErrorType.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
|
errorType: errorType,
|
||||||
);
|
);
|
||||||
_failedInSession++;
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
@@ -1164,10 +1445,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
_log.e('Exception: $e', e, stackTrace);
|
_log.e('Exception: $e', e, stackTrace);
|
||||||
|
|
||||||
|
String errorMsg = e.toString();
|
||||||
|
DownloadErrorType errorType = DownloadErrorType.unknown;
|
||||||
|
|
||||||
|
// Check for specific Deezer fallback error
|
||||||
|
if (errorMsg.contains('could not find Deezer equivalent') ||
|
||||||
|
errorMsg.contains('track not found on Deezer')) {
|
||||||
|
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
||||||
|
errorType = DownloadErrorType.notFound;
|
||||||
|
}
|
||||||
|
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: e.toString(),
|
error: errorMsg,
|
||||||
|
errorType: errorType,
|
||||||
);
|
);
|
||||||
_failedInSession++;
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ 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/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
|
||||||
@@ -18,8 +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
|
// Apply Spotify credentials to Go backend on load
|
||||||
_applySpotifyCredentials();
|
_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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +126,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setUpdateChannel(String channel) {
|
||||||
|
state = state.copyWith(updateChannel: channel);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setHasSearchedBefore() {
|
void setHasSearchedBefore() {
|
||||||
if (!state.hasSearchedBefore) {
|
if (!state.hasSearchedBefore) {
|
||||||
state = state.copyWith(hasSearchedBefore: true);
|
state = state.copyWith(hasSearchedBefore: true);
|
||||||
@@ -151,6 +186,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
_applySpotifyCredentials();
|
_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;
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
/// Check if request is still valid (not cancelled by newer request)
|
/// Check if request is still valid (not cancelled by newer request)
|
||||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url) async {
|
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
@@ -127,7 +127,22 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
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 (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
@@ -149,6 +164,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
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>;
|
||||||
@@ -160,6 +177,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
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') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
@@ -180,7 +199,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query) async {
|
Future<void> search(String query, {String? metadataSource}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
// Increment request ID to cancel any pending requests
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
@@ -188,14 +207,57 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
// Use Deezer or Spotify based on settings
|
||||||
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
|
// Debug log to show which source is being used
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Using metadata source: $source for query: "$query"');
|
||||||
|
|
||||||
|
Map<String, dynamic> results;
|
||||||
|
if (source == 'deezer') {
|
||||||
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||||
|
} else {
|
||||||
|
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||||
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
// Parse tracks with error handling per item
|
||||||
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
|
final tracks = <Track>[];
|
||||||
|
for (final t in trackList) {
|
||||||
|
try {
|
||||||
|
if (t is Map<String, dynamic>) {
|
||||||
|
tracks.add(_parseSearchTrack(t));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Failed to parse track: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse artists with error handling per item
|
||||||
|
final artists = <SearchArtist>[];
|
||||||
|
for (final a in artistList) {
|
||||||
|
try {
|
||||||
|
if (a is Map<String, dynamic>) {
|
||||||
|
artists.add(_parseSearchArtist(a));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Failed to parse artist: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[Search] Parsed ${tracks.length} tracks, ${artists.length} artists');
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -274,18 +336,27 @@ 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) / 1000).round(),
|
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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +381,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
popularity: data['popularity'] 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>(
|
||||||
|
|||||||
@@ -71,8 +71,22 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
Future<void> _fetchTracks() async {
|
Future<void> _fetchTracks() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
Map<String, dynamic> metadata;
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
|
||||||
|
// 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 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();
|
||||||
|
|
||||||
@@ -302,8 +316,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
void _downloadTrack(BuildContext context, Track track) {
|
void _downloadTrack(BuildContext context, Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
} else {
|
} else {
|
||||||
@@ -317,8 +331,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
if (tracks == null || tracks.isEmpty) return;
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||||
} else {
|
} else {
|
||||||
@@ -327,44 +341,69 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
String selectedService = settings.defaultService;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
builder: (context) => SafeArea(
|
isScrollControlled: true,
|
||||||
child: Column(
|
builder: (context) => StatefulBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, setModalState) => SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Column(
|
||||||
if (trackName != null) ...[
|
mainAxisSize: MainAxisSize.min,
|
||||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
children: [
|
||||||
] else ...[
|
if (trackName != null) ...[
|
||||||
const SizedBox(height: 8),
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
],
|
] else ...[
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
],
|
||||||
),
|
// Service selector
|
||||||
// Disclaimer
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
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),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
),
|
||||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
|
||||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -455,6 +494,40 @@ class _QualityOption extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
final String trackName;
|
final String trackName;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
|
|||||||
@@ -69,10 +69,25 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Future<void> _fetchDiscography() async {
|
Future<void> _fetchDiscography() async {
|
||||||
setState(() => _isLoadingDiscography = true);
|
setState(() => _isLoadingDiscography = true);
|
||||||
try {
|
try {
|
||||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
List<ArtistAlbum> albums;
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
if (widget.artistId.startsWith('deezer:')) {
|
||||||
|
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
|
||||||
|
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
||||||
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
} else {
|
||||||
|
// Spotify artist - use fallback method
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
|
||||||
|
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||||
|
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
|
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Store in cache
|
// Store in cache
|
||||||
_ArtistCache.set(widget.artistId, albums);
|
_ArtistCache.set(widget.artistId, albums);
|
||||||
@@ -144,6 +159,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
// Validate image URL - must be non-null, non-empty, and have a valid host
|
||||||
|
final hasValidImage = widget.coverUrl != null &&
|
||||||
|
widget.coverUrl!.isNotEmpty &&
|
||||||
|
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 280,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
@@ -154,8 +174,15 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
background: Stack(
|
background: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
if (widget.coverUrl != null)
|
if (hasValidImage)
|
||||||
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
memCacheWidth: 600,
|
||||||
|
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
|
||||||
|
),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -177,8 +204,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: widget.coverUrl != null
|
child: hasValidImage
|
||||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
? CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 280,
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
)
|
||||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -290,7 +325,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
|
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
'${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks',
|
album.totalTracks > 0
|
||||||
|
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks'
|
||||||
|
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
||||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
} else {
|
} else {
|
||||||
await ref.read(trackProvider.notifier).search(url);
|
final settings = ref.read(settingsProvider);
|
||||||
|
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
if (_lastSearchQuery == query) return;
|
if (_lastSearchQuery == query) return;
|
||||||
_lastSearchQuery = query;
|
_lastSearchQuery = query;
|
||||||
|
|
||||||
await ref.read(trackProvider.notifier).search(query);
|
final settings = ref.read(settingsProvider);
|
||||||
|
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +113,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
_navigateToDetailIfNeeded();
|
_navigateToDetailIfNeeded();
|
||||||
} else {
|
} else {
|
||||||
await ref.read(trackProvider.notifier).search(url);
|
final settings = ref.read(settingsProvider);
|
||||||
|
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
|
||||||
}
|
}
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
}
|
}
|
||||||
@@ -170,8 +172,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
} else {
|
} else {
|
||||||
@@ -181,59 +183,84 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
String selectedService = settings.defaultService;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
builder: (context) => SafeArea(
|
isScrollControlled: true,
|
||||||
child: Column(
|
builder: (context) => StatefulBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, setModalState) => SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Column(
|
||||||
if (trackName != null) ...[
|
mainAxisSize: MainAxisSize.min,
|
||||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
children: [
|
||||||
] else ...[
|
if (trackName != null) ...[
|
||||||
const SizedBox(height: 8),
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
],
|
] else ...[
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
],
|
||||||
),
|
// Service selector
|
||||||
// Disclaimer
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_QualityPickerOption(
|
||||||
|
title: 'FLAC Lossless',
|
||||||
|
subtitle: '16-bit / 44.1kHz',
|
||||||
|
icon: Icons.music_note,
|
||||||
|
onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); },
|
||||||
|
),
|
||||||
|
_QualityPickerOption(
|
||||||
|
title: 'Hi-Res FLAC',
|
||||||
|
subtitle: '24-bit / up to 96kHz',
|
||||||
|
icon: Icons.high_quality,
|
||||||
|
onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); },
|
||||||
|
),
|
||||||
|
_QualityPickerOption(
|
||||||
|
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),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_QualityPickerOption(
|
),
|
||||||
title: 'FLAC Lossless',
|
|
||||||
subtitle: '16-bit / 44.1kHz',
|
|
||||||
icon: Icons.music_note,
|
|
||||||
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
|
|
||||||
),
|
|
||||||
_QualityPickerOption(
|
|
||||||
title: 'Hi-Res FLAC',
|
|
||||||
subtitle: '24-bit / up to 96kHz',
|
|
||||||
icon: Icons.high_quality,
|
|
||||||
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
|
|
||||||
),
|
|
||||||
_QualityPickerOption(
|
|
||||||
title: 'Hi-Res FLAC Max',
|
|
||||||
subtitle: '24-bit / up to 192kHz',
|
|
||||||
icon: Icons.four_k,
|
|
||||||
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -624,6 +651,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
||||||
|
// Validate image URL - must be non-null, non-empty, and have a valid host
|
||||||
|
final hasValidImage = artist.imageUrl != null &&
|
||||||
|
artist.imageUrl!.isNotEmpty &&
|
||||||
|
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -639,12 +671,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: artist.imageUrl != null
|
child: hasValidImage
|
||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: artist.imageUrl!,
|
imageUrl: artist.imageUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 200,
|
memCacheWidth: 200,
|
||||||
memCacheHeight: 200,
|
memCacheHeight: 200,
|
||||||
|
errorWidget: (context, url, error) => Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 44,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
||||||
),
|
),
|
||||||
@@ -764,6 +801,40 @@ class _QualityPickerOption extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
final String trackName;
|
final String trackName;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (!settings.checkForUpdates) return;
|
if (!settings.checkForUpdates) return;
|
||||||
|
|
||||||
final updateInfo = await UpdateChecker.checkForUpdate();
|
final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel);
|
||||||
if (updateInfo != null && mounted) {
|
if (updateInfo != null && mounted) {
|
||||||
showUpdateDialog(
|
showUpdateDialog(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, ref, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
} else {
|
} else {
|
||||||
@@ -182,8 +182,8 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
_showQualityPicker(context, (quality) {
|
_showQualityPicker(context, ref, (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||||
} else {
|
} else {
|
||||||
@@ -192,41 +192,66 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
String selectedService = settings.defaultService;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
builder: (context) => SafeArea(
|
isScrollControlled: true,
|
||||||
child: Column(
|
builder: (context) => StatefulBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, setModalState) => SafeArea(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
child: Column(
|
||||||
if (trackName != null) ...[
|
mainAxisSize: MainAxisSize.min,
|
||||||
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
children: [
|
||||||
] else ...[
|
if (trackName != null) ...[
|
||||||
const SizedBox(height: 8),
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
],
|
] else ...[
|
||||||
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
|
const SizedBox(height: 8),
|
||||||
// Disclaimer
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
Padding(
|
],
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
// Service selector
|
||||||
child: Text(
|
Padding(
|
||||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
),
|
||||||
),
|
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),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
),
|
||||||
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }),
|
|
||||||
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -254,6 +279,40 @@ class _QualityOption extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
final String trackName;
|
final String trackName;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
|
|||||||
@@ -331,8 +331,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
// Show percentage and speed
|
||||||
Text(
|
Text(
|
||||||
'${(item.progress * 100).toStringAsFixed(0)}%',
|
item.speedMBps > 0
|
||||||
|
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
|
: '${(item.progress * 100).toStringAsFixed(0)}%',
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -344,7 +347,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (item.status == DownloadStatus.failed) ...[
|
if (item.status == DownloadStatus.failed) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
item.error ?? 'Download failed',
|
item.errorMessage, // Use user-friendly error message
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
_searchController = TextEditingController(text: widget.query);
|
_searchController = TextEditingController(text: widget.query);
|
||||||
if (widget.query.isNotEmpty) {
|
if (widget.query.isNotEmpty) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ref.read(trackProvider.notifier).search(widget.query);
|
final settings = ref.read(settingsProvider);
|
||||||
|
ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,7 +38,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
void _search() {
|
void _search() {
|
||||||
final query = _searchController.text.trim();
|
final query = _searchController.text.trim();
|
||||||
if (query.isNotEmpty) {
|
if (query.isNotEmpty) {
|
||||||
ref.read(trackProvider.notifier).search(query);
|
final settings = ref.read(settingsProvider);
|
||||||
|
ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,49 @@ class AboutPage extends StatelessWidget {
|
|||||||
name: AppInfo.originalAuthor,
|
name: AppInfo.originalAuthor,
|
||||||
description: 'Creator of the original SpotiFLAC',
|
description: 'Creator of the original SpotiFLAC',
|
||||||
githubUsername: AppInfo.originalAuthor,
|
githubUsername: AppInfo.originalAuthor,
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_ContributorItem(
|
||||||
|
name: 'Amonoman',
|
||||||
|
description: 'The talented artist who created our beautiful app logo!',
|
||||||
|
githubUsername: 'Amonoman',
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Special Thanks section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Special Thanks'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_ContributorItem(
|
||||||
|
name: 'uimaxbai',
|
||||||
|
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
|
||||||
|
githubUsername: 'uimaxbai',
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_ContributorItem(
|
||||||
|
name: 'sachinsenal0x64',
|
||||||
|
description: 'The original HiFi project creator. The foundation of Tidal integration!',
|
||||||
|
githubUsername: 'sachinsenal0x64',
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.cloud_outlined,
|
||||||
|
title: 'DoubleDouble',
|
||||||
|
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
||||||
|
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.music_note_outlined,
|
||||||
|
title: 'DAB Music',
|
||||||
|
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
||||||
|
onTap: () => _launchUrl('https://dabmusic.xyz'),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -123,6 +166,24 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Support section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Support'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.coffee_outlined,
|
||||||
|
title: 'Buy me a coffee',
|
||||||
|
subtitle: 'Support development on Ko-fi',
|
||||||
|
onTap: () => _launchUrl(AppInfo.kofiUrl),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// App info section
|
// App info section
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'App'),
|
child: SettingsSectionHeader(title: 'App'),
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
currentMode: themeSettings.themeMode,
|
currentMode: themeSettings.themeMode,
|
||||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||||
),
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.brightness_2,
|
||||||
|
title: 'AMOLED Dark',
|
||||||
|
subtitle: 'Pure black background for OLED screens',
|
||||||
|
value: themeSettings.useAmoled,
|
||||||
|
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'dart:io';
|
||||||
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:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
@@ -113,8 +115,10 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
title: 'Download Directory',
|
title: 'Download Directory',
|
||||||
subtitle: settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory,
|
subtitle: settings.downloadDirectory.isEmpty
|
||||||
onTap: () => _pickDirectory(ref),
|
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
|
||||||
|
: settings.downloadDirectory,
|
||||||
|
onTap: () => _pickDirectory(context, ref),
|
||||||
),
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.create_new_folder_outlined,
|
icon: Icons.create_new_folder_outlined,
|
||||||
@@ -161,9 +165,90 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickDirectory(WidgetRef ref) async {
|
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
if (Platform.isIOS) {
|
||||||
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
// iOS: Show options dialog
|
||||||
|
_showIOSDirectoryOptions(context, ref);
|
||||||
|
} else {
|
||||||
|
// Android: Use file picker
|
||||||
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
|
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||||
|
title: const Text('App Documents Folder'),
|
||||||
|
subtitle: const Text('Recommended - accessible via Files app'),
|
||||||
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
|
onTap: () async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
ref.read(settingsProvider.notifier).setDownloadDirectory(dir.path);
|
||||||
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||||
|
title: const Text('Choose from Files'),
|
||||||
|
subtitle: const Text('Select iCloud or other location'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (result != null) {
|
||||||
|
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getFolderOrganizationLabel(String value) {
|
String _getFolderOrganizationLabel(String value) {
|
||||||
|
|||||||
@@ -0,0 +1,801 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
|
class LogScreen extends StatefulWidget {
|
||||||
|
const LogScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LogScreen> createState() => _LogScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogScreenState extends State<LogScreen> {
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
String _selectedLevel = 'ALL';
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _autoScroll = true;
|
||||||
|
|
||||||
|
final List<String> _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
LogBuffer().addListener(_onLogUpdate);
|
||||||
|
// Start polling Go backend logs
|
||||||
|
LogBuffer().startGoLogPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
LogBuffer().removeListener(_onLogUpdate);
|
||||||
|
// Stop polling when leaving screen
|
||||||
|
LogBuffer().stopGoLogPolling();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLogUpdate() {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
if (_autoScroll && _scrollController.hasClients) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LogEntry> get _filteredLogs {
|
||||||
|
return LogBuffer().filter(
|
||||||
|
level: _selectedLevel,
|
||||||
|
search: _searchQuery.isEmpty ? null : _searchQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _copyLogs() {
|
||||||
|
final logs = LogBuffer().export();
|
||||||
|
Clipboard.setData(ClipboardData(text: logs));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Logs copied to clipboard'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shareLogs() {
|
||||||
|
final logs = LogBuffer().export();
|
||||||
|
SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearLogs() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear Logs'),
|
||||||
|
content: const Text('Are you sure you want to clear all logs?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
LogBuffer().clear();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getLevelColor(String level, ColorScheme colorScheme) {
|
||||||
|
switch (level) {
|
||||||
|
case 'ERROR':
|
||||||
|
case 'FATAL':
|
||||||
|
return colorScheme.error;
|
||||||
|
case 'WARN':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'INFO':
|
||||||
|
return colorScheme.primary;
|
||||||
|
case 'DEBUG':
|
||||||
|
default:
|
||||||
|
return colorScheme.onSurfaceVariant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
final logs = _filteredLogs;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: true,
|
||||||
|
child: Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [
|
||||||
|
// Collapsing App Bar with back button - same as other settings pages
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 120 + topPadding,
|
||||||
|
collapsedHeight: kToolbarHeight,
|
||||||
|
floating: false,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
||||||
|
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
|
||||||
|
onPressed: () => setState(() => _autoScroll = !_autoScroll),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
tooltip: 'Copy logs',
|
||||||
|
onPressed: _copyLogs,
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'share':
|
||||||
|
_shareLogs();
|
||||||
|
break;
|
||||||
|
case 'clear':
|
||||||
|
_clearLogs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'share',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.share),
|
||||||
|
title: Text('Share logs'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'clear',
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete_outline),
|
||||||
|
title: Text('Clear logs'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxHeight = 120 + topPadding;
|
||||||
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
|
final leftPadding = 56 - (32 * expandRatio);
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
expandedTitleScale: 1.0,
|
||||||
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
|
title: Text(
|
||||||
|
'Logs',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (8 * expandRatio),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Filter section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Filter'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
// Level filter
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Filter logs by severity',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownButton<String>(
|
||||||
|
value: _selectedLevel,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
items: _levels.map((level) {
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: level,
|
||||||
|
child: Text(
|
||||||
|
level,
|
||||||
|
style: TextStyle(
|
||||||
|
color: level == 'ALL'
|
||||||
|
? colorScheme.onSurface
|
||||||
|
: _getLevelColor(level, colorScheme),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() => _selectedLevel = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 56,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
// Search field
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search logs...',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() => _searchQuery = '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _searchQuery = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Log entries section
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(
|
||||||
|
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error summary card - shows detected issues
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _LogSummaryCard(logs: LogBuffer().entries),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Log list
|
||||||
|
logs.isEmpty
|
||||||
|
? SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.article_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No logs yet',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Logs will appear here as you use the app',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
...logs.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final log = entry.value;
|
||||||
|
return _LogEntryTile(
|
||||||
|
entry: log,
|
||||||
|
levelColor: _getLevelColor(log.level, colorScheme),
|
||||||
|
showDivider: index < logs.length - 1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom padding
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogEntryTile extends StatelessWidget {
|
||||||
|
final LogEntry entry;
|
||||||
|
final Color levelColor;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const _LogEntryTile({
|
||||||
|
required this.entry,
|
||||||
|
required this.levelColor,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isError = entry.level == 'ERROR' || entry.level == 'FATAL';
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isError
|
||||||
|
? colorScheme.errorContainer.withValues(alpha: 0.2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header: time, level, tag
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.formattedTime,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: levelColor.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
entry.level,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: levelColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (entry.isFromGo) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.teal.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Go',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.teal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
entry.tag,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Message
|
||||||
|
Text(
|
||||||
|
entry.message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Error if present
|
||||||
|
if (entry.error != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
entry.error!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.error,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 20,
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary card showing detected issues in logs
|
||||||
|
class _LogSummaryCard extends StatelessWidget {
|
||||||
|
final List<LogEntry> logs;
|
||||||
|
|
||||||
|
const _LogSummaryCard({required this.logs});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Analyze logs for issues
|
||||||
|
final analysis = _analyzeLogs();
|
||||||
|
|
||||||
|
// Don't show if no issues detected
|
||||||
|
if (!analysis.hasIssues) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: analysis.hasISPBlocking
|
||||||
|
? colorScheme.errorContainer.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Issue Summary',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ISP Blocking detected
|
||||||
|
if (analysis.hasISPBlocking) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.block,
|
||||||
|
label: 'ISP BLOCKING DETECTED',
|
||||||
|
description: 'Your ISP may be blocking access to download services',
|
||||||
|
suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8',
|
||||||
|
color: colorScheme.error,
|
||||||
|
domains: analysis.blockedDomains,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
if (analysis.hasRateLimit) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.speed,
|
||||||
|
label: 'RATE LIMITED',
|
||||||
|
description: 'Too many requests to the service',
|
||||||
|
suggestion: 'Wait a few minutes before trying again',
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Network errors
|
||||||
|
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.wifi_off,
|
||||||
|
label: 'NETWORK ERROR',
|
||||||
|
description: 'Connection issues detected',
|
||||||
|
suggestion: 'Check your internet connection',
|
||||||
|
color: colorScheme.tertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Track not found
|
||||||
|
if (analysis.hasNotFound) ...[
|
||||||
|
_IssueBadge(
|
||||||
|
icon: Icons.search_off,
|
||||||
|
label: 'TRACK NOT FOUND',
|
||||||
|
description: 'Some tracks could not be found on download services',
|
||||||
|
suggestion: 'The track may not be available in lossless quality',
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Error count
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Total errors: ${analysis.errorCount}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_LogAnalysis _analyzeLogs() {
|
||||||
|
int errorCount = 0;
|
||||||
|
bool hasISPBlocking = false;
|
||||||
|
bool hasRateLimit = false;
|
||||||
|
bool hasNetworkError = false;
|
||||||
|
bool hasNotFound = false;
|
||||||
|
final Set<String> blockedDomains = {};
|
||||||
|
|
||||||
|
for (final log in logs) {
|
||||||
|
if (log.level == 'ERROR' || log.level == 'FATAL') {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
final msgLower = log.message.toLowerCase();
|
||||||
|
final errorLower = (log.error ?? '').toLowerCase();
|
||||||
|
final combined = '$msgLower $errorLower';
|
||||||
|
|
||||||
|
// Check for ISP blocking (detected by Go backend)
|
||||||
|
if (combined.contains('isp blocking') ||
|
||||||
|
combined.contains('isp may be') ||
|
||||||
|
combined.contains('blocked by isp') ||
|
||||||
|
combined.contains('connection reset') ||
|
||||||
|
combined.contains('connection refused')) {
|
||||||
|
hasISPBlocking = true;
|
||||||
|
|
||||||
|
// Try to extract domain
|
||||||
|
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
||||||
|
if (domainMatch != null) {
|
||||||
|
blockedDomains.add(domainMatch.group(1)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rate limiting
|
||||||
|
if (combined.contains('rate limit') ||
|
||||||
|
combined.contains('429') ||
|
||||||
|
combined.contains('too many requests')) {
|
||||||
|
hasRateLimit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for network errors
|
||||||
|
if (combined.contains('connection') ||
|
||||||
|
combined.contains('timeout') ||
|
||||||
|
combined.contains('network') ||
|
||||||
|
combined.contains('dial')) {
|
||||||
|
hasNetworkError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for not found
|
||||||
|
if (combined.contains('not found') ||
|
||||||
|
combined.contains('no results') ||
|
||||||
|
combined.contains('could not find')) {
|
||||||
|
hasNotFound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _LogAnalysis(
|
||||||
|
errorCount: errorCount,
|
||||||
|
hasISPBlocking: hasISPBlocking,
|
||||||
|
hasRateLimit: hasRateLimit,
|
||||||
|
hasNetworkError: hasNetworkError,
|
||||||
|
hasNotFound: hasNotFound,
|
||||||
|
blockedDomains: blockedDomains.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogAnalysis {
|
||||||
|
final int errorCount;
|
||||||
|
final bool hasISPBlocking;
|
||||||
|
final bool hasRateLimit;
|
||||||
|
final bool hasNetworkError;
|
||||||
|
final bool hasNotFound;
|
||||||
|
final List<String> blockedDomains;
|
||||||
|
|
||||||
|
_LogAnalysis({
|
||||||
|
required this.errorCount,
|
||||||
|
required this.hasISPBlocking,
|
||||||
|
required this.hasRateLimit,
|
||||||
|
required this.hasNetworkError,
|
||||||
|
required this.hasNotFound,
|
||||||
|
required this.blockedDomains,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IssueBadge extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
final String suggestion;
|
||||||
|
final Color color;
|
||||||
|
final List<String>? domains;
|
||||||
|
|
||||||
|
const _IssueBadge({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
required this.suggestion,
|
||||||
|
required this.color,
|
||||||
|
this.domains,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: color),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (domains != null && domains!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Affected: ${domains!.join(", ")}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
suggestion,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,7 +105,10 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
subtitle: 'Notify when new version is available',
|
subtitle: 'Notify when new version is available',
|
||||||
value: settings.checkForUpdates,
|
value: settings.checkForUpdates,
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
|
||||||
showDivider: false,
|
),
|
||||||
|
_UpdateChannelSelector(
|
||||||
|
currentChannel: settings.updateChannel,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setUpdateChannel(v),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -116,29 +119,35 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
_MetadataSourceSelector(
|
||||||
icon: Icons.key,
|
currentSource: settings.metadataSource,
|
||||||
title: 'Custom Credentials',
|
onChanged: (v) => ref.read(settingsProvider.notifier).setMetadataSource(v),
|
||||||
subtitle: settings.spotifyClientId.isNotEmpty
|
|
||||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
|
||||||
: 'Not configured',
|
|
||||||
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
|
||||||
trailing: settings.spotifyClientId.isNotEmpty
|
|
||||||
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
|
||||||
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
|
||||||
showDivider: settings.spotifyClientId.isNotEmpty,
|
|
||||||
),
|
),
|
||||||
if (settings.spotifyClientId.isNotEmpty)
|
if (settings.metadataSource == 'spotify') ...[
|
||||||
SettingsSwitchItem(
|
SettingsItem(
|
||||||
icon: Icons.toggle_on,
|
icon: Icons.key,
|
||||||
title: 'Use Custom Credentials',
|
title: 'Custom Credentials',
|
||||||
subtitle: settings.useCustomSpotifyCredentials
|
subtitle: settings.spotifyClientId.isNotEmpty
|
||||||
? 'Using your credentials'
|
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||||
: 'Using default credentials',
|
: 'Not configured',
|
||||||
value: settings.useCustomSpotifyCredentials,
|
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
||||||
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
trailing: settings.spotifyClientId.isNotEmpty
|
||||||
showDivider: false,
|
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
||||||
|
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
||||||
|
showDivider: settings.spotifyClientId.isNotEmpty,
|
||||||
),
|
),
|
||||||
|
if (settings.spotifyClientId.isNotEmpty)
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.toggle_on,
|
||||||
|
title: 'Use Custom Credentials',
|
||||||
|
subtitle: settings.useCustomSpotifyCredentials
|
||||||
|
? 'Using your credentials'
|
||||||
|
: 'Using default credentials',
|
||||||
|
value: settings.useCustomSpotifyCredentials,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -159,6 +168,25 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Debug section
|
||||||
|
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Debug')),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.bug_report,
|
||||||
|
title: 'Detailed Logging',
|
||||||
|
subtitle: settings.enableLogging
|
||||||
|
? 'Detailed logs are being recorded'
|
||||||
|
: 'Enable for bug reports',
|
||||||
|
value: settings.enableLogging,
|
||||||
|
onChanged: (v) => ref.read(settingsProvider.notifier).setEnableLogging(v),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -393,3 +421,149 @@ class _ConcurrentChip extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _UpdateChannelSelector extends StatelessWidget {
|
||||||
|
final String currentChannel;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
const _UpdateChannelSelector({required this.currentChannel, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.new_releases, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('Update Channel', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(currentChannel == 'preview' ? 'Get preview releases' : 'Stable releases only',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
])),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(children: [
|
||||||
|
_ChannelChip(label: 'Stable', isSelected: currentChannel == 'stable', onTap: () => onChanged('stable')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ChannelChip(label: 'Preview', isSelected: currentChannel == 'preview', onTap: () => onChanged('preview')),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text('Preview may contain bugs or incomplete features',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ChannelChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final unselectedColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(child: Text(label, style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetadataSourceSelector extends StatelessWidget {
|
||||||
|
final String currentSource;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
const _MetadataSourceSelector({required this.currentSource, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.search, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('Search Source', style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(currentSource == 'deezer' ? 'Deezer (no need developer account)' : 'Spotify (may hit rate limit)',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
])),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(children: [
|
||||||
|
_SourceChip(label: 'Deezer', isSelected: currentSource == 'deezer', onTap: () => onChanged('deezer')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_SourceChip(label: 'Spotify', isSelected: currentSource == 'spotify', onTap: () => onChanged('spotify')),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(children: [
|
||||||
|
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text('Spotify URLs are always supported regardless of this setting',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SourceChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _SourceChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final unselectedColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||||
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(child: Text(label, style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart
|
|||||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||||
|
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class SettingsTab extends ConsumerWidget {
|
class SettingsTab extends ConsumerWidget {
|
||||||
@@ -67,10 +68,16 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Second group: About
|
// Second group: Logs & About
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.article_outlined,
|
||||||
|
title: 'Logs',
|
||||||
|
subtitle: 'View app logs for debugging',
|
||||||
|
onTap: () => _navigateTo(context, const LogScreen()),
|
||||||
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
title: 'About',
|
title: 'About',
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
String? _selectedDirectory;
|
String? _selectedDirectory;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
|
|
||||||
|
// Spotify API credentials
|
||||||
|
final _clientIdController = TextEditingController();
|
||||||
|
final _clientSecretController = TextEditingController();
|
||||||
|
bool _useSpotifyApi = false;
|
||||||
|
bool _showClientSecret = false;
|
||||||
|
|
||||||
// Total steps: Storage -> Notification (Android 13+) -> Folder
|
// Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
|
||||||
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
|
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -33,6 +39,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
_initDeviceInfo();
|
_initDeviceInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_clientIdController.dispose();
|
||||||
|
_clientSecretController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initDeviceInfo() async {
|
Future<void> _initDeviceInfo() async {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
@@ -205,29 +218,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
if (Platform.isIOS) {
|
||||||
dialogTitle: 'Select Download Folder',
|
// iOS: Show options dialog
|
||||||
);
|
await _showIOSDirectoryOptions();
|
||||||
|
|
||||||
if (selectedDirectory != null) {
|
|
||||||
setState(() => _selectedDirectory = selectedDirectory);
|
|
||||||
} else {
|
} else {
|
||||||
final defaultDir = await _getDefaultDirectory();
|
// Android: Use file picker
|
||||||
if (mounted) {
|
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||||
final useDefault = await showDialog<bool>(
|
dialogTitle: 'Select Download Folder',
|
||||||
context: context,
|
);
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Use Default Folder?'),
|
|
||||||
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (useDefault == true) {
|
if (selectedDirectory != null) {
|
||||||
setState(() => _selectedDirectory = defaultDir);
|
setState(() => _selectedDirectory = selectedDirectory);
|
||||||
|
} else {
|
||||||
|
final defaultDir = await _getDefaultDirectory();
|
||||||
|
if (mounted) {
|
||||||
|
final useDefault = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Use Default Folder?'),
|
||||||
|
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useDefault == true) {
|
||||||
|
setState(() => _selectedDirectory = defaultDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +255,82 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showIOSDirectoryOptions() async {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
|
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||||
|
title: const Text('App Documents Folder'),
|
||||||
|
subtitle: const Text('Recommended - accessible via Files app'),
|
||||||
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
|
onTap: () async {
|
||||||
|
final dir = await _getDefaultDirectory();
|
||||||
|
setState(() => _selectedDirectory = dir);
|
||||||
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||||
|
title: const Text('Choose from Files'),
|
||||||
|
subtitle: const Text('Select iCloud or other location'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (result != null) {
|
||||||
|
setState(() => _selectedDirectory = result);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> _getDefaultDirectory() async {
|
Future<String> _getDefaultDirectory() async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
final appDir = await getApplicationDocumentsDirectory();
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
@@ -276,6 +371,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
||||||
|
|
||||||
|
// Save Spotify credentials if provided
|
||||||
|
if (_useSpotifyApi &&
|
||||||
|
_clientIdController.text.trim().isNotEmpty &&
|
||||||
|
_clientSecretController.text.trim().isNotEmpty) {
|
||||||
|
ref.read(settingsProvider.notifier).setSpotifyCredentials(
|
||||||
|
_clientIdController.text.trim(),
|
||||||
|
_clientSecretController.text.trim(),
|
||||||
|
);
|
||||||
|
ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true);
|
||||||
|
// Set search source to Spotify when using custom credentials
|
||||||
|
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||||
|
} else {
|
||||||
|
// Use Deezer as default search source
|
||||||
|
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||||
|
}
|
||||||
|
|
||||||
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
|
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -354,8 +466,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
||||||
final steps = _androidSdkVersion >= 33
|
final steps = _androidSdkVersion >= 33
|
||||||
? ['Storage', 'Notification', 'Folder']
|
? ['Storage', 'Notification', 'Folder', 'Spotify']
|
||||||
: ['Permission', 'Folder'];
|
: ['Permission', 'Folder', 'Spotify'];
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -379,48 +491,61 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
||||||
final isActive = _currentStep >= step;
|
final isActive = _currentStep >= step;
|
||||||
final isCompleted = _isStepCompleted(step);
|
final isCompleted = _isStepCompleted(step);
|
||||||
|
final isCurrent = _currentStep == step;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
AnimatedContainer(
|
||||||
width: 32,
|
duration: const Duration(milliseconds: 200),
|
||||||
height: 32,
|
width: 36,
|
||||||
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: isCompleted
|
color: isCompleted
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
: isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
: isCurrent
|
||||||
|
? colorScheme.primaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
border: isCurrent && !isCompleted
|
||||||
|
? Border.all(color: colorScheme.primary, width: 2)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: isCompleted
|
child: isCompleted
|
||||||
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
|
? Icon(Icons.check_rounded, size: 20, color: colorScheme.onPrimary)
|
||||||
: Text('${step + 1}',
|
: Text('${step + 1}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
color: isCurrent ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.bold)),
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 6),
|
||||||
Text(label,
|
Text(label,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
|
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isStepCompleted(int step) {
|
bool _isStepCompleted(int step) {
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// 3 steps: Storage, Notification, Folder
|
// 4 steps: Storage, Notification, Folder, Spotify
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _notificationPermissionGranted;
|
case 1: return _notificationPermissionGranted;
|
||||||
case 2: return _selectedDirectory != null;
|
case 2: return _selectedDirectory != null;
|
||||||
|
case 3: return false; // Spotify step never shows checkmark (optional)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 2 steps: Permission, Folder
|
// 3 steps: Permission, Folder, Spotify
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _selectedDirectory != null;
|
case 1: return _selectedDirectory != null;
|
||||||
|
case 2: return false; // Spotify step never shows checkmark (optional)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -432,11 +557,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||||
case 1: return _buildNotificationPermissionStep(colorScheme);
|
case 1: return _buildNotificationPermissionStep(colorScheme);
|
||||||
case 2: return _buildDirectoryStep(colorScheme);
|
case 2: return _buildDirectoryStep(colorScheme);
|
||||||
|
case 3: return _buildSpotifyApiStep(colorScheme);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||||
case 1: return _buildDirectoryStep(colorScheme);
|
case 1: return _buildDirectoryStep(colorScheme);
|
||||||
|
case 2: return _buildSpotifyApiStep(colorScheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
@@ -447,35 +574,50 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// Icon with container background (M3 style)
|
||||||
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
|
Container(
|
||||||
size: 56,
|
width: 80,
|
||||||
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _storagePermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_storagePermissionGranted ? Icons.check_rounded : Icons.folder_open_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _storagePermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Padding(
|
||||||
_storagePermissionGranted
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
? 'You can now proceed to the next step.'
|
child: Text(
|
||||||
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
_storagePermissionGranted
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
? 'You can now proceed to the next step.'
|
||||||
textAlign: TextAlign.center,
|
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 24),
|
||||||
if (!_storagePermissionGranted)
|
if (!_storagePermissionGranted)
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _requestStoragePermission,
|
onPressed: _isLoading ? null : _requestStoragePermission,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.security),
|
: const Icon(Icons.security_rounded),
|
||||||
label: const Text('Grant Permission'),
|
label: const Text('Grant Permission'),
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -486,39 +628,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// Icon with container background (M3 style)
|
||||||
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
|
Container(
|
||||||
size: 56,
|
width: 80,
|
||||||
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _notificationPermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_notificationPermissionGranted ? Icons.check_rounded : Icons.notifications_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: _notificationPermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Padding(
|
||||||
_notificationPermissionGranted
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
? 'You will receive download progress notifications.'
|
child: Text(
|
||||||
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
_notificationPermissionGranted
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
? 'You will receive download progress notifications.'
|
||||||
textAlign: TextAlign.center,
|
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 24),
|
||||||
if (!_notificationPermissionGranted) ...[
|
if (!_notificationPermissionGranted) ...[
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _requestNotificationPermission,
|
onPressed: _isLoading ? null : _requestNotificationPermission,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.notifications_active),
|
: const Icon(Icons.notifications_active_rounded),
|
||||||
label: const Text('Enable Notifications'),
|
label: const Text('Enable Notifications'),
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _skipNotificationPermission,
|
onPressed: _skipNotificationPermission,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
),
|
||||||
child: const Text('Skip for now'),
|
child: const Text('Skip for now'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -531,51 +691,226 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// Icon with container background (M3 style)
|
||||||
_selectedDirectory != null ? Icons.folder : Icons.create_new_folder,
|
Container(
|
||||||
size: 56,
|
width: 80,
|
||||||
color: _selectedDirectory != null ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _selectedDirectory != null ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_selectedDirectory != null ? Icons.folder_rounded : Icons.create_new_folder_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _selectedDirectory != null ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (_selectedDirectory != null)
|
if (_selectedDirectory != null)
|
||||||
Container(
|
Card(
|
||||||
padding: const EdgeInsets.all(12),
|
elevation: 0,
|
||||||
decoration: BoxDecoration(
|
color: colorScheme.surfaceContainerHigh,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.folder, color: colorScheme.primary, size: 20),
|
Icon(Icons.folder_rounded, color: colorScheme.primary, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 12),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(_selectedDirectory!,
|
child: Text(
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
_selectedDirectory!,
|
||||||
overflow: TextOverflow.ellipsis),
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Text('Select a folder where your downloaded music will be saved.',
|
Padding(
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
textAlign: TextAlign.center),
|
child: Text(
|
||||||
const SizedBox(height: 20),
|
'Select a folder where your downloaded music will be saved.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _selectDirectory,
|
onPressed: _isLoading ? null : _selectDirectory,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
|
: Icon(_selectedDirectory != null ? Icons.edit_rounded : Icons.folder_open_rounded),
|
||||||
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSpotifyApiStep(ColorScheme colorScheme) {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Icon with container background (M3 style)
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _useSpotifyApi ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.api_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _useSpotifyApi ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Spotify API (Optional)',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Add your Spotify API credentials for better search results, or skip to use Deezer instead.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Toggle card (M3 style)
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: SwitchListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
title: Text('Use Spotify API', style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
subtitle: Text(
|
||||||
|
_useSpotifyApi ? 'Enter your credentials below' : 'Using Deezer (no account needed)',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
secondary: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _useSpotifyApi ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_useSpotifyApi ? Icons.music_note_rounded : Icons.album_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: _useSpotifyApi ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
value: _useSpotifyApi,
|
||||||
|
onChanged: (value) => setState(() => _useSpotifyApi = value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Credentials form (animated)
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
child: _useSpotifyApi ? Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Client ID
|
||||||
|
Text('Client ID', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _clientIdController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Enter Spotify Client ID',
|
||||||
|
prefixIcon: const Icon(Icons.key_rounded),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Client Secret
|
||||||
|
Text('Client Secret', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _clientSecretController,
|
||||||
|
obscureText: !_showClientSecret,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Enter Spotify Client Secret',
|
||||||
|
prefixIcon: const Icon(Icons.lock_rounded),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
||||||
|
onPressed: () => setState(() => _showClientSecret = !_showClientSecret),
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Info banner
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline_rounded, size: 20, color: colorScheme.onTertiaryContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Get credentials from developer.spotify.com',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) : const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -584,6 +919,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
||||||
final isLastStep = _currentStep == _totalSteps - 1;
|
final isLastStep = _currentStep == _totalSteps - 1;
|
||||||
final canProceed = _isStepCompleted(_currentStep);
|
final canProceed = _isStepCompleted(_currentStep);
|
||||||
|
|
||||||
|
// For Spotify step, check if credentials are valid when enabled
|
||||||
|
final isSpotifyStepValid = !_useSpotifyApi ||
|
||||||
|
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -592,8 +931,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (_currentStep > 0)
|
if (_currentStep > 0)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
onPressed: () => setState(() => _currentStep--),
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
label: const Text('Back'),
|
label: const Text('Back'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
const SizedBox(width: 100),
|
const SizedBox(width: 100),
|
||||||
@@ -602,20 +944,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (!isLastStep)
|
if (!isLastStep)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
|
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward_rounded, size: 18)],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
|
onPressed: isSpotifyStepValid && !_isLoading ? _completeSetup : null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Row(
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
|
children: [
|
||||||
|
Text(_useSpotifyApi ? 'Get Started' : 'Skip & Start'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.check_rounded, size: 18),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -37,11 +37,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
final file = File(widget.item.filePath);
|
final file = File(widget.item.filePath);
|
||||||
final exists = await file.exists();
|
final exists = await file.exists();
|
||||||
int? size;
|
int? size;
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
try {
|
try {
|
||||||
size = await file.length();
|
size = await file.length();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_fileExists = exists;
|
_fileExists = exists;
|
||||||
@@ -55,7 +57,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use data directly from history item (cached from download)
|
||||||
DownloadHistoryItem get item => widget.item;
|
DownloadHistoryItem get item => widget.item;
|
||||||
|
String get trackName => item.trackName;
|
||||||
|
String get artistName => item.artistName;
|
||||||
|
String get albumName => item.albumName;
|
||||||
|
String? get albumArtist => item.albumArtist;
|
||||||
|
int? get trackNumber => item.trackNumber;
|
||||||
|
int? get discNumber => item.discNumber;
|
||||||
|
String? get releaseDate => item.releaseDate;
|
||||||
|
String? get isrc => item.isrc;
|
||||||
|
int? get bitDepth => item.bitDepth;
|
||||||
|
int? get sampleRate => item.sampleRate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -233,9 +246,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Track name
|
// Track name (from file metadata)
|
||||||
Text(
|
Text(
|
||||||
item.trackName,
|
trackName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
@@ -243,16 +256,16 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Artist name
|
// Artist name (from file metadata)
|
||||||
Text(
|
Text(
|
||||||
item.artistName,
|
artistName,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Album name
|
// Album name (from file metadata)
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -263,7 +276,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item.albumName,
|
albumName,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -401,28 +414,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
// Build audio quality string from file metadata
|
||||||
|
String? audioQualityStr;
|
||||||
|
if (bitDepth != null && sampleRate != null) {
|
||||||
|
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
||||||
|
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
|
||||||
|
}
|
||||||
|
|
||||||
final items = <_MetadataItem>[
|
final items = <_MetadataItem>[
|
||||||
_MetadataItem('Track name', item.trackName),
|
_MetadataItem('Track name', trackName),
|
||||||
_MetadataItem('Artist', item.artistName),
|
_MetadataItem('Artist', artistName),
|
||||||
if (item.albumArtist != null && item.albumArtist != item.artistName)
|
if (albumArtist != null && albumArtist != artistName)
|
||||||
_MetadataItem('Album artist', item.albumArtist!),
|
_MetadataItem('Album artist', albumArtist!),
|
||||||
_MetadataItem('Album', item.albumName),
|
_MetadataItem('Album', albumName),
|
||||||
if (item.trackNumber != null)
|
if (trackNumber != null && trackNumber! > 0)
|
||||||
_MetadataItem('Track number', item.trackNumber.toString()),
|
_MetadataItem('Track number', trackNumber.toString()),
|
||||||
if (item.discNumber != null && item.discNumber! > 1)
|
if (discNumber != null && discNumber! > 1)
|
||||||
_MetadataItem('Disc number', item.discNumber.toString()),
|
_MetadataItem('Disc number', discNumber.toString()),
|
||||||
if (item.duration != null)
|
if (item.duration != null)
|
||||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||||
if (item.quality != null && item.quality!.contains('bit'))
|
if (audioQualityStr != null)
|
||||||
_MetadataItem('Audio quality', item.quality!),
|
_MetadataItem('Audio quality', audioQualityStr),
|
||||||
if (item.releaseDate != null && item.releaseDate!.isNotEmpty)
|
if (releaseDate != null && releaseDate!.isNotEmpty)
|
||||||
_MetadataItem('Release date', item.releaseDate!),
|
_MetadataItem('Release date', releaseDate!),
|
||||||
if (item.isrc != null && item.isrc!.isNotEmpty)
|
if (isrc != null && isrc!.isNotEmpty)
|
||||||
_MetadataItem('ISRC', item.isrc!),
|
_MetadataItem('ISRC', isrc!),
|
||||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||||
_MetadataItem('Spotify ID', item.spotifyId!),
|
_MetadataItem('Spotify ID', item.spotifyId!),
|
||||||
if (item.quality != null && item.quality!.isNotEmpty)
|
|
||||||
_MetadataItem('Quality', _formatQuality(item.quality!)),
|
|
||||||
_MetadataItem('Service', item.service.toUpperCase()),
|
_MetadataItem('Service', item.service.toUpperCase()),
|
||||||
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
||||||
];
|
];
|
||||||
@@ -476,32 +494,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatQuality(String quality) {
|
|
||||||
switch (quality) {
|
|
||||||
case 'LOSSLESS':
|
|
||||||
return 'Lossless (16-bit)';
|
|
||||||
case 'HI_RES':
|
|
||||||
return 'Hi-Res (24-bit)';
|
|
||||||
case 'HI_RES_LOSSLESS':
|
|
||||||
return 'Hi-Res Lossless (24-bit)';
|
|
||||||
default:
|
|
||||||
return quality;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatQualityShort(String quality) {
|
|
||||||
switch (quality) {
|
|
||||||
case 'LOSSLESS':
|
|
||||||
return '16-bit';
|
|
||||||
case 'HI_RES':
|
|
||||||
return '24-bit';
|
|
||||||
case 'HI_RES_LOSSLESS':
|
|
||||||
return 'Hi-Res';
|
|
||||||
default:
|
|
||||||
return quality;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
||||||
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
||||||
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
||||||
@@ -570,7 +562,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.quality != null)
|
if (bitDepth != null && sampleRate != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -578,7 +570,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_formatQualityShort(item.quality!),
|
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -759,17 +751,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Add timeout to prevent infinite loading
|
||||||
final result = await PlatformBridge.getLyricsLRC(
|
final result = await PlatformBridge.getLyricsLRC(
|
||||||
item.spotifyId ?? '',
|
item.spotifyId ?? '',
|
||||||
item.trackName,
|
item.trackName,
|
||||||
item.artistName,
|
item.artistName,
|
||||||
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||||
|
).timeout(
|
||||||
|
const Duration(seconds: 20),
|
||||||
|
onTimeout: () => '', // Return empty string on timeout
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
if (result.isEmpty) {
|
if (result.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = 'Lyrics not found';
|
_lyricsError = 'Lyrics not available for this track';
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -783,8 +779,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
final errorMsg = e.toString().contains('TimeoutException')
|
||||||
|
? 'Request timed out. Try again later.'
|
||||||
|
: 'Failed to load lyrics';
|
||||||
setState(() {
|
setState(() {
|
||||||
_lyricsError = 'Failed to load lyrics';
|
_lyricsError = errorMsg;
|
||||||
_lyricsLoading = false;
|
_lyricsLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -884,7 +883,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.delete, color: colorScheme.error),
|
leading: Icon(Icons.delete, color: colorScheme.error),
|
||||||
title: Text('Remove from history', style: TextStyle(color: colorScheme.error)),
|
title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_confirmDelete(context, ref, colorScheme);
|
_confirmDelete(context, ref, colorScheme);
|
||||||
@@ -901,10 +900,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Remove from history?'),
|
title: const Text('Remove from device?'),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'This will remove the track from your download history. '
|
'This will permanently delete the downloaded file and remove it from your history.',
|
||||||
'The downloaded file will not be deleted.',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -912,12 +910,26 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
|
// Delete the file first
|
||||||
|
try {
|
||||||
|
final file = File(item.filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to delete file: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from history
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
||||||
Navigator.pop(context); // Close dialog
|
|
||||||
Navigator.pop(context); // Go back to history
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context); // Close dialog
|
||||||
|
Navigator.pop(context); // Go back to history
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
child: Text('Delete', style: TextStyle(color: colorScheme.error)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
final _log = AppLogger('FFmpeg');
|
||||||
@@ -133,22 +134,83 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed cover art to FLAC file
|
/// Embed metadata and cover art to FLAC file
|
||||||
/// Returns the file path on success, null on failure
|
/// Returns the file path on success, null on failure
|
||||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
static Future<String?> embedMetadata({
|
||||||
final tempOutput = '$flacPath.tmp';
|
required String flacPath,
|
||||||
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';
|
String? coverPath,
|
||||||
|
Map<String, String>? metadata,
|
||||||
|
}) async {
|
||||||
|
// Android Scoped Storage: Cannot write directly to Music folder with FFmpeg
|
||||||
|
// Use app-internal cache directory for temp output
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
|
||||||
|
|
||||||
|
// 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);
|
final result = await _execute(command);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
try {
|
try {
|
||||||
// Replace original with temp
|
// Copy temp output back to original location (replace)
|
||||||
await File(flacPath).delete();
|
final tempFile = File(tempOutput);
|
||||||
await File(tempOutput).rename(flacPath);
|
final originalFile = File(flacPath);
|
||||||
return flacPath;
|
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
// Delete original file
|
||||||
|
if (await originalFile.exists()) {
|
||||||
|
await originalFile.delete();
|
||||||
|
}
|
||||||
|
// Copy temp file to original location
|
||||||
|
await tempFile.copy(flacPath);
|
||||||
|
// Delete temp file
|
||||||
|
await tempFile.delete();
|
||||||
|
|
||||||
|
return flacPath;
|
||||||
|
} else {
|
||||||
|
_log.e('Temp output file not found: $tempOutput');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to replace file after cover embed: $e');
|
_log.e('Failed to replace file after metadata embed: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +223,7 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
_log.e('Cover embed failed: ${result.output}');
|
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('PlatformBridge');
|
||||||
|
|
||||||
/// Bridge to communicate with Go backend via platform channels
|
/// Bridge to communicate with Go backend via platform channels
|
||||||
class PlatformBridge {
|
class PlatformBridge {
|
||||||
@@ -7,18 +10,21 @@ class PlatformBridge {
|
|||||||
|
|
||||||
/// Parse and validate Spotify URL
|
/// Parse and validate Spotify URL
|
||||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||||
|
_log.d('parseSpotifyUrl: $url');
|
||||||
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Spotify metadata from URL
|
/// Get Spotify metadata from URL
|
||||||
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
||||||
|
_log.d('getSpotifyMetadata: $url');
|
||||||
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search Spotify
|
/// Search Spotify
|
||||||
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
||||||
|
_log.d('searchSpotify: "$query" (limit: $limit)');
|
||||||
final result = await _channel.invokeMethod('searchSpotify', {
|
final result = await _channel.invokeMethod('searchSpotify', {
|
||||||
'query': query,
|
'query': query,
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
@@ -28,6 +34,7 @@ class PlatformBridge {
|
|||||||
|
|
||||||
/// Search Spotify for tracks and artists
|
/// Search Spotify for tracks and artists
|
||||||
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||||
|
_log.d('searchSpotifyAll: "$query"');
|
||||||
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
||||||
'query': query,
|
'query': query,
|
||||||
'track_limit': trackLimit,
|
'track_limit': trackLimit,
|
||||||
@@ -38,6 +45,7 @@ class PlatformBridge {
|
|||||||
|
|
||||||
/// Check track availability on streaming services
|
/// Check track availability on streaming services
|
||||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||||
|
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||||
final result = await _channel.invokeMethod('checkAvailability', {
|
final result = await _channel.invokeMethod('checkAvailability', {
|
||||||
'spotify_id': spotifyId,
|
'spotify_id': spotifyId,
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -67,6 +75,7 @@ class PlatformBridge {
|
|||||||
String? itemId,
|
String? itemId,
|
||||||
int durationMs = 0,
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
|
_log.i('downloadTrack: "$trackName" by $artistName via $service');
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'service': service,
|
'service': service,
|
||||||
@@ -90,7 +99,13 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
final result = await _channel.invokeMethod('downloadTrack', request);
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
if (response['success'] == true) {
|
||||||
|
_log.i('Download success: ${response['file_path']}');
|
||||||
|
} else {
|
||||||
|
_log.w('Download failed: ${response['error']}');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download with automatic fallback to other services
|
/// Download with automatic fallback to other services
|
||||||
@@ -115,6 +130,7 @@ class PlatformBridge {
|
|||||||
String? itemId,
|
String? itemId,
|
||||||
int durationMs = 0,
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
|
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'service': preferredService,
|
'service': preferredService,
|
||||||
@@ -138,7 +154,22 @@ class PlatformBridge {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
if (response['success'] == true) {
|
||||||
|
final service = response['service'] ?? 'unknown';
|
||||||
|
final filePath = response['file_path'] ?? '';
|
||||||
|
final bitDepth = response['actual_bit_depth'];
|
||||||
|
final sampleRate = response['actual_sample_rate'];
|
||||||
|
final qualityStr = bitDepth != null && sampleRate != null
|
||||||
|
? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)'
|
||||||
|
: '';
|
||||||
|
_log.i('Download success via $service$qualityStr: $filePath');
|
||||||
|
} else {
|
||||||
|
final error = response['error'] ?? 'Unknown error';
|
||||||
|
final errorType = response['error_type'] ?? '';
|
||||||
|
_log.e('Download failed: $error (type: $errorType)');
|
||||||
|
}
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get download progress (legacy single download)
|
/// Get download progress (legacy single download)
|
||||||
@@ -248,6 +279,16 @@ class PlatformBridge {
|
|||||||
await _channel.invokeMethod('cleanupConnections');
|
await _channel.invokeMethod('cleanupConnections');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read metadata directly from a FLAC file
|
||||||
|
/// Returns all embedded metadata (title, artist, album, track number, etc.)
|
||||||
|
/// This reads from the actual file, not from cached/database data
|
||||||
|
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
|
||||||
|
final result = await _channel.invokeMethod('readFileMetadata', {
|
||||||
|
'file_path': filePath,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Start foreground download service to keep downloads running in background
|
/// Start foreground download service to keep downloads running in background
|
||||||
static Future<void> startDownloadService({
|
static Future<void> startDownloadService({
|
||||||
String trackName = '',
|
String trackName = '',
|
||||||
@@ -297,4 +338,105 @@ class PlatformBridge {
|
|||||||
'client_secret': clientSecret,
|
'client_secret': clientSecret,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pre-warm track ID cache for album/playlist tracks
|
||||||
|
/// This runs in background and returns immediately
|
||||||
|
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
|
||||||
|
static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async {
|
||||||
|
final tracksJson = jsonEncode(tracks);
|
||||||
|
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current track cache size
|
||||||
|
static Future<int> getTrackCacheSize() async {
|
||||||
|
final result = await _channel.invokeMethod('getTrackCacheSize');
|
||||||
|
return result as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear track ID cache
|
||||||
|
static Future<void> clearTrackCache() async {
|
||||||
|
await _channel.invokeMethod('clearTrackCache');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DEEZER API ====================
|
||||||
|
|
||||||
|
/// Search Deezer for tracks and artists (no API key required)
|
||||||
|
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||||
|
final result = await _channel.invokeMethod('searchDeezerAll', {
|
||||||
|
'query': query,
|
||||||
|
'track_limit': trackLimit,
|
||||||
|
'artist_limit': artistLimit,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Deezer metadata by type and ID
|
||||||
|
static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async {
|
||||||
|
final result = await _channel.invokeMethod('getDeezerMetadata', {
|
||||||
|
'resource_type': resourceType,
|
||||||
|
'resource_id': resourceId,
|
||||||
|
});
|
||||||
|
if (result == null) {
|
||||||
|
throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId');
|
||||||
|
}
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Deezer URL and return type and ID
|
||||||
|
static Future<Map<String, dynamic>> parseDeezerUrl(String url) async {
|
||||||
|
final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search Deezer by ISRC
|
||||||
|
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
|
||||||
|
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
|
||||||
|
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
|
||||||
|
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
|
||||||
|
'resource_type': resourceType,
|
||||||
|
'spotify_id': spotifyId,
|
||||||
|
});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Spotify metadata with automatic Deezer fallback on rate limit
|
||||||
|
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async {
|
||||||
|
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GO BACKEND LOGS ====================
|
||||||
|
|
||||||
|
/// Get all logs from Go backend
|
||||||
|
static Future<List<Map<String, dynamic>>> getGoLogs() async {
|
||||||
|
final result = await _channel.invokeMethod('getLogs');
|
||||||
|
final logs = jsonDecode(result as String) as List<dynamic>;
|
||||||
|
return logs.map((e) => e as Map<String, dynamic>).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get logs since a specific index (for incremental updates)
|
||||||
|
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
||||||
|
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
|
||||||
|
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear Go backend logs
|
||||||
|
static Future<void> clearGoLogs() async {
|
||||||
|
await _channel.invokeMethod('clearLogs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Go backend log count
|
||||||
|
static Future<int> getGoLogCount() async {
|
||||||
|
final result = await _channel.invokeMethod('getLogCount');
|
||||||
|
return result as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable Go backend logging
|
||||||
|
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
||||||
|
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class UpdateInfo {
|
|||||||
final String downloadUrl;
|
final String downloadUrl;
|
||||||
final String? apkDownloadUrl;
|
final String? apkDownloadUrl;
|
||||||
final DateTime publishedAt;
|
final DateTime publishedAt;
|
||||||
|
final bool isPrerelease;
|
||||||
|
|
||||||
const UpdateInfo({
|
const UpdateInfo({
|
||||||
required this.version,
|
required this.version,
|
||||||
@@ -19,11 +20,13 @@ class UpdateInfo {
|
|||||||
required this.downloadUrl,
|
required this.downloadUrl,
|
||||||
this.apkDownloadUrl,
|
this.apkDownloadUrl,
|
||||||
required this.publishedAt,
|
required this.publishedAt,
|
||||||
|
this.isPrerelease = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class UpdateChecker {
|
class UpdateChecker {
|
||||||
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||||
|
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
|
||||||
|
|
||||||
static Future<String> _getDeviceArch() async {
|
static Future<String> _getDeviceArch() async {
|
||||||
if (!Platform.isAndroid) return 'unknown';
|
if (!Platform.isAndroid) return 'unknown';
|
||||||
@@ -55,30 +58,59 @@ class UpdateChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<UpdateInfo?> checkForUpdate() async {
|
/// Check for updates based on channel preference
|
||||||
|
/// [channel] can be 'stable' or 'preview'
|
||||||
|
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
|
||||||
try {
|
try {
|
||||||
final response = await http.get(
|
Map<String, dynamic>? releaseData;
|
||||||
Uri.parse(_apiUrl),
|
|
||||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
if (channel == 'preview') {
|
||||||
).timeout(const Duration(seconds: 10));
|
// For preview channel, get all releases and find the latest (including prereleases)
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('$_allReleasesApiUrl?per_page=10'),
|
||||||
|
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
_log.w('GitHub API returned ${response.statusCode}');
|
_log.w('GitHub API returned ${response.statusCode}');
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final releases = jsonDecode(response.body) as List<dynamic>;
|
||||||
|
if (releases.isEmpty) {
|
||||||
|
_log.i('No releases found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First release is the latest (including prereleases)
|
||||||
|
releaseData = releases.first as Map<String, dynamic>;
|
||||||
|
} else {
|
||||||
|
// For stable channel, use /latest endpoint (excludes prereleases)
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(_latestApiUrl),
|
||||||
|
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
_log.w('GitHub API returned ${response.statusCode}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final tagName = releaseData['tag_name'] as String? ?? '';
|
||||||
final tagName = data['tag_name'] as String? ?? '';
|
|
||||||
final latestVersion = tagName.replaceFirst('v', '');
|
final latestVersion = tagName.replaceFirst('v', '');
|
||||||
|
final isPrerelease = releaseData['prerelease'] as bool? ?? false;
|
||||||
|
|
||||||
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
||||||
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)');
|
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final body = data['body'] as String? ?? 'No changelog available';
|
final body = releaseData['body'] as String? ?? 'No changelog available';
|
||||||
final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||||
final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now();
|
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
|
||||||
|
|
||||||
final deviceArch = await _getDeviceArch();
|
final deviceArch = await _getDeviceArch();
|
||||||
_log.d('Device architecture: $deviceArch');
|
_log.d('Device architecture: $deviceArch');
|
||||||
@@ -87,7 +119,7 @@ class UpdateChecker {
|
|||||||
String? arm32Url;
|
String? arm32Url;
|
||||||
String? universalUrl;
|
String? universalUrl;
|
||||||
|
|
||||||
final assets = data['assets'] as List<dynamic>? ?? [];
|
final assets = releaseData['assets'] as List<dynamic>? ?? [];
|
||||||
for (final asset in assets) {
|
for (final asset in assets) {
|
||||||
final name = (asset['name'] as String? ?? '').toLowerCase();
|
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||||
if (name.endsWith('.apk')) {
|
if (name.endsWith('.apk')) {
|
||||||
@@ -117,7 +149,7 @@ class UpdateChecker {
|
|||||||
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Update available: $latestVersion, APK URL: $apkUrl');
|
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');
|
||||||
|
|
||||||
return UpdateInfo(
|
return UpdateInfo(
|
||||||
version: latestVersion,
|
version: latestVersion,
|
||||||
@@ -125,6 +157,7 @@ class UpdateChecker {
|
|||||||
downloadUrl: htmlUrl,
|
downloadUrl: htmlUrl,
|
||||||
apkDownloadUrl: apkUrl,
|
apkDownloadUrl: apkUrl,
|
||||||
publishedAt: publishedAt,
|
publishedAt: publishedAt,
|
||||||
|
isPrerelease: isPrerelease,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Error checking for updates: $e');
|
_log.e('Error checking for updates: $e');
|
||||||
|
|||||||