Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74bc747599 | |||
| cbc8fdcb0c | |||
| 3b79b4f1ca | |||
| 5692a76650 | |||
| 7a009ad0af | |||
| e5e75e7092 | |||
| 01b8fd2480 | |||
| ee807a44cc | |||
| c9b905eb18 | |||
| e9c7bf830e | |||
| 8bc97d5bd3 | |||
| f2c241c323 | |||
| 9c512ffe28 | |||
| 53a1da6249 | |||
| d4274e8ca8 | |||
| 49a9f12841 | |||
| d7fa040e3c | |||
| 9baa1e2088 | |||
| 482457205a | |||
| 3b2ec319e2 | |||
| a0f7e75a9a | |||
| c725e53e4c | |||
| 1d7c43a302 | |||
| df7c1c5bb7 | |||
| bb05353b7e | |||
| 7ac92d77e5 | |||
| cf00ecb756 | |||
| 525f2fd0cd | |||
| 3e841cef06 | |||
| a8527df80a | |||
| 51b2ad5c77 | |||
| d641a517b8 | |||
| 608fa2ca74 | |||
| 343b309314 | |||
| 0787b32dd8 | |||
| 6927fdf7a9 | |||
| fe6af34478 | |||
| 85bb67da47 | |||
| 794486a200 | |||
| 8ce5e958ee | |||
| 5c6bf02f1c | |||
| 852335f794 | |||
| b87de1f00a | |||
| 8fcb389bb2 | |||
| 08bca30fcd | |||
| a7c5afdd20 | |||
| 5eac386eba | |||
| d35d60ac7d | |||
| 7c43d4bf70 | |||
| 2043370b6c | |||
| 39ddb7a14f | |||
| bd9b527161 | |||
| 39bcc2c547 | |||
| 973c2e3b41 | |||
| 62805720da | |||
| 0d8234ccd2 | |||
| 0edd616c3d | |||
| 9ca0e8cf5c | |||
| 37b8682faa | |||
| 6563f0f2b3 | |||
| 562fd4d7bb | |||
| 7aa3e77df1 | |||
| 4caa803eb2 | |||
| 6d5c9d0f91 | |||
| 1b2ad4cdd5 | |||
| 33e8ddd758 | |||
| d227d57545 | |||
| db1439e08f | |||
| 47e7850ee0 | |||
| 3ea665dab4 | |||
| bd4acdf222 | |||
| 8ac679003e | |||
| 6a1265eac3 | |||
| 9570547ff9 | |||
| ef62fb218a | |||
| ba5c91090c | |||
| c454bcd5ee | |||
| 4d2ee6fca6 | |||
| 89851bbd62 |
@@ -1,75 +0,0 @@
|
|||||||
name: Android Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-android:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Java
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: 'temurin'
|
|
||||||
java-version: '17'
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.21'
|
|
||||||
cache-dependency-path: go_backend/go.sum
|
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
|
||||||
uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
- name: Install gomobile
|
|
||||||
run: |
|
|
||||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
|
||||||
gomobile init
|
|
||||||
|
|
||||||
- name: Build Go backend for Android
|
|
||||||
working-directory: go_backend
|
|
||||||
run: |
|
|
||||||
mkdir -p ../android/app/libs
|
|
||||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
|
|
||||||
- name: Setup Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: 'stable'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate app icons
|
|
||||||
run: dart run flutter_launcher_icons
|
|
||||||
|
|
||||||
- name: Build APK (Release)
|
|
||||||
run: flutter build apk --release
|
|
||||||
|
|
||||||
- name: Build App Bundle (Release)
|
|
||||||
run: flutter build appbundle --release
|
|
||||||
|
|
||||||
- name: Upload APK artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SpotiFLAC-Android-APK
|
|
||||||
path: build/app/outputs/flutter-apk/app-release.apk
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload AAB artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SpotiFLAC-Android-AAB
|
|
||||||
path: build/app/outputs/bundle/release/app-release.aab
|
|
||||||
retention-days: 30
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
name: Auto Release on Version Bump
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'pubspec.yaml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version_changed: ${{ steps.check.outputs.changed }}
|
|
||||||
new_version: ${{ steps.check.outputs.version }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Check if version changed
|
|
||||||
id: check
|
|
||||||
run: |
|
|
||||||
# Get current version
|
|
||||||
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
|
||||||
|
|
||||||
# Get previous version
|
|
||||||
git show HEAD~1:pubspec.yaml > /tmp/old_pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > /tmp/old_pubspec.yaml
|
|
||||||
PREVIOUS_VERSION=$(grep '^version:' /tmp/old_pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
|
||||||
|
|
||||||
echo "Current version: $CURRENT_VERSION"
|
|
||||||
echo "Previous version: $PREVIOUS_VERSION"
|
|
||||||
|
|
||||||
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
|
||||||
echo "Version changed!"
|
|
||||||
echo "changed=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "version=v$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "Version unchanged"
|
|
||||||
echo "changed=false" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
create-tag-and-trigger-release:
|
|
||||||
needs: check-version
|
|
||||||
if: needs.check-version.outputs.version_changed == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Create and push tag
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag ${{ needs.check-version.outputs.new_version }}
|
|
||||||
git push origin ${{ needs.check-version.outputs.new_version }}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Trigger Release workflow
|
|
||||||
run: |
|
|
||||||
gh workflow run release.yml -f version=${{ needs.check-version.outputs.new_version }}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Auto Tag on Version Change
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'pubspec.yaml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2 # Need previous commit to compare
|
||||||
|
|
||||||
|
- name: Get current version
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Current version: $VERSION"
|
||||||
|
|
||||||
|
- name: Get previous version
|
||||||
|
id: previous
|
||||||
|
run: |
|
||||||
|
git checkout HEAD~1 -- pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > pubspec.yaml.old
|
||||||
|
if [ -f pubspec.yaml.old ]; then
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml.old | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
else
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
fi
|
||||||
|
git checkout HEAD -- pubspec.yaml
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Previous version: $VERSION"
|
||||||
|
|
||||||
|
- name: Check if version changed
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
CURRENT="${{ steps.current.outputs.version }}"
|
||||||
|
PREVIOUS="${{ steps.previous.outputs.version }}"
|
||||||
|
|
||||||
|
if [ "$CURRENT" != "$PREVIOUS" ]; then
|
||||||
|
echo "Version changed from $PREVIOUS to $CURRENT"
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Version unchanged: $CURRENT"
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check if tag exists
|
||||||
|
id: tag_exists
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.current.outputs.version }}"
|
||||||
|
if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then
|
||||||
|
echo "Tag $TAG already exists"
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Tag $TAG does not exist"
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
if: steps.check.outputs.changed == 'true' && steps.tag_exists.outputs.exists == 'false'
|
||||||
|
run: |
|
||||||
|
TAG="v${{ steps.current.outputs.version }}"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag -a "$TAG" -m "Release $TAG"
|
||||||
|
git push origin "$TAG"
|
||||||
|
echo "Created and pushed tag: $TAG"
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
name: iOS Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-ios:
|
|
||||||
runs-on: macos-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.21'
|
|
||||||
cache-dependency-path: go_backend/go.sum
|
|
||||||
|
|
||||||
- name: Install gomobile
|
|
||||||
run: |
|
|
||||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
|
||||||
gomobile init
|
|
||||||
|
|
||||||
- name: Build Go backend for iOS (XCFramework)
|
|
||||||
working-directory: go_backend
|
|
||||||
run: |
|
|
||||||
mkdir -p ../ios/Frameworks
|
|
||||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
|
|
||||||
- name: Verify XCFramework created
|
|
||||||
run: |
|
|
||||||
echo "=== Checking XCFramework ==="
|
|
||||||
ls -la ios/Frameworks/
|
|
||||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
|
||||||
|
|
||||||
- name: Add XCFramework to Xcode project
|
|
||||||
run: |
|
|
||||||
# Install xcodeproj gem for modifying Xcode project
|
|
||||||
sudo gem install xcodeproj
|
|
||||||
|
|
||||||
# Create Ruby script to add framework
|
|
||||||
cat > add_framework.rb << 'EOF'
|
|
||||||
require 'xcodeproj'
|
|
||||||
|
|
||||||
project_path = 'ios/Runner.xcodeproj'
|
|
||||||
project = Xcodeproj::Project.open(project_path)
|
|
||||||
|
|
||||||
# Get the main target
|
|
||||||
target = project.targets.find { |t| t.name == 'Runner' }
|
|
||||||
|
|
||||||
# Get or create Frameworks group
|
|
||||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
|
||||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
|
||||||
|
|
||||||
# Add XCFramework reference
|
|
||||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
|
||||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
|
||||||
|
|
||||||
# Add to frameworks build phase
|
|
||||||
frameworks_build_phase = target.frameworks_build_phase
|
|
||||||
frameworks_build_phase.add_file_reference(framework_ref)
|
|
||||||
|
|
||||||
# Add to embed frameworks build phase
|
|
||||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
|
||||||
if embed_phase
|
|
||||||
build_file = embed_phase.add_file_reference(framework_ref)
|
|
||||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
|
||||||
end
|
|
||||||
|
|
||||||
project.save
|
|
||||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
|
||||||
EOF
|
|
||||||
|
|
||||||
ruby add_framework.rb
|
|
||||||
|
|
||||||
- name: Setup Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: 'stable'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate app icons
|
|
||||||
run: dart run flutter_launcher_icons
|
|
||||||
|
|
||||||
- name: Build iOS (no codesign)
|
|
||||||
run: flutter build ios --release --no-codesign
|
|
||||||
|
|
||||||
- name: Create IPA (unsigned)
|
|
||||||
run: |
|
|
||||||
mkdir -p build/ios/ipa
|
|
||||||
cd build/ios/iphoneos
|
|
||||||
mkdir Payload
|
|
||||||
cp -r Runner.app Payload/
|
|
||||||
zip -r ../ipa/SpotiFLAC-unsigned.ipa Payload
|
|
||||||
rm -rf Payload
|
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SpotiFLAC-iOS-unsigned
|
|
||||||
path: build/ios/ipa/SpotiFLAC-unsigned.ipa
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload XCFramework artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Gobackend-XCFramework
|
|
||||||
path: ios/Frameworks/Gobackend.xcframework
|
|
||||||
retention-days: 30
|
|
||||||
@@ -3,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)
|
||||||
@@ -17,35 +17,61 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||||
steps:
|
steps:
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
VERSION="${{ github.event.inputs.version }}"
|
||||||
else
|
else
|
||||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||||
|
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||||
|
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||||
|
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "Detected pre-release version: $VERSION"
|
||||||
|
else
|
||||||
|
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Detected stable version: $VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Android and iOS build in PARALLEL
|
# Android and iOS build in PARALLEL
|
||||||
build-android:
|
build-android:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: get-version
|
needs: get-version
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Free disk space
|
||||||
|
run: |
|
||||||
|
# Remove large unused tools (~15GB total)
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||||
|
sudo rm -rf /usr/local/share/boost
|
||||||
|
sudo rm -rf /usr/share/swift
|
||||||
|
sudo rm -rf /usr/local/.ghcup
|
||||||
|
# Clean docker images
|
||||||
|
sudo docker image prune --all --force
|
||||||
|
# Show available space
|
||||||
|
df -h
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: "temurin"
|
||||||
java-version: '17'
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: "1.21"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -59,7 +85,19 @@ 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 (required for gomobile)
|
||||||
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;25.2.9519653" "platforms;android-34" "build-tools;34.0.0"
|
||||||
|
|
||||||
|
# Set NDK path
|
||||||
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.2.9519653" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Install gomobile
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -77,7 +115,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
|
||||||
@@ -86,16 +124,36 @@ jobs:
|
|||||||
- name: Generate app icons
|
- name: Generate app icons
|
||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build APK (Release)
|
- name: Build APK (Release - unsigned)
|
||||||
run: flutter build apk --release --split-per-abi
|
run: |
|
||||||
|
flutter build apk --release --split-per-abi || true
|
||||||
|
# Verify APKs were created
|
||||||
|
ls -la build/app/outputs/flutter-apk/
|
||||||
|
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
||||||
|
echo "ERROR: APK not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Sign APKs
|
||||||
|
uses: r0adkll/sign-android-release@v1
|
||||||
|
id: sign_arm64
|
||||||
|
with:
|
||||||
|
releaseDirectory: build/app/outputs/flutter-apk
|
||||||
|
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
alias: ${{ secrets.KEY_ALIAS }}
|
||||||
|
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
env:
|
||||||
|
BUILD_TOOLS_VERSION: "34.0.0"
|
||||||
|
|
||||||
- name: Rename APKs
|
- name: Rename APKs
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cd build/app/outputs/flutter-apk
|
cd build/app/outputs/flutter-apk
|
||||||
mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
# Signed files have -signed suffix
|
||||||
mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||||
mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||||
|
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
@@ -106,8 +164,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
|
||||||
@@ -115,7 +173,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
|
||||||
@@ -143,53 +201,70 @@ 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)
|
||||||
|
- name: Use iOS pubspec with FFmpeg plugin
|
||||||
|
run: |
|
||||||
|
cp pubspec.yaml pubspec_android_backup.yaml
|
||||||
|
cp pubspec_ios.yaml pubspec.yaml
|
||||||
|
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
|
||||||
|
|
||||||
|
# Swap FFmpeg service for iOS
|
||||||
|
- name: Use iOS FFmpeg service
|
||||||
|
run: |
|
||||||
|
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
|
||||||
|
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
|
||||||
|
# Update class name in the swapped file
|
||||||
|
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
|
||||||
|
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
|
||||||
|
echo "Swapped to iOS FFmpeg service"
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -197,18 +272,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:
|
||||||
@@ -220,8 +321,36 @@ 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
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract changelog for version
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||||
|
|
||||||
|
echo "Looking for version: $VERSION_NUM"
|
||||||
|
|
||||||
|
# Extract changelog section for this version using sed
|
||||||
|
# Find the line with version, then print until next version header or end
|
||||||
|
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||||
|
|
||||||
|
# If no changelog found, use default message
|
||||||
|
if [ -z "$CHANGELOG" ]; then
|
||||||
|
echo "No changelog found for version $VERSION_NUM"
|
||||||
|
CHANGELOG="See CHANGELOG.md for details."
|
||||||
|
else
|
||||||
|
echo "Found changelog content"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save to file for multiline support
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
|
echo "Extracted changelog:"
|
||||||
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -234,26 +363,52 @@ jobs:
|
|||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
|
- name: Prepare release body
|
||||||
|
run: |
|
||||||
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
|
cat > /tmp/release_body.txt << 'HEADER'
|
||||||
|
### What's New
|
||||||
|
HEADER
|
||||||
|
|
||||||
|
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||||
|
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Downloads
|
||||||
|
|
||||||
|
#### Android
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
||||||
|
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||||
|
|
||||||
|
#### iOS
|
||||||
|

|
||||||
|
|
||||||
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
FOOTER
|
||||||
|
|
||||||
|
echo "Release body:"
|
||||||
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||||
body: |
|
body_path: /tmp/release_body.txt
|
||||||
## SpotiFLAC ${{ needs.get-version.outputs.version }}
|
|
||||||
|
|
||||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
|
||||||
|
|
||||||
### Downloads
|
|
||||||
- **Android (arm64)**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-arm64.apk` (recommended)
|
|
||||||
- **Android (arm32)**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-arm32.apk` (older devices)
|
|
||||||
- **iOS**: `SpotiFLAC-${{ needs.get-version.outputs.version }}-ios-unsigned.ipa` (sideload required)
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -7,8 +7,46 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
# Kiro specs (optional - remove if you want to track specs)
|
# Kiro specs (development only)
|
||||||
# .kiro/
|
.kiro/
|
||||||
|
|
||||||
# Reference folder (if you don't want to include it)
|
# Reference folder (development only)
|
||||||
# referensi/
|
referensi/
|
||||||
|
|
||||||
|
# Old spotiflac_android folder (moved to root)
|
||||||
|
spotiflac_android/
|
||||||
|
|
||||||
|
# Flutter/Dart
|
||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
build/
|
||||||
|
*.lock
|
||||||
|
!pubspec.lock
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.metadata
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
# Go backend build artifacts
|
||||||
|
go_backend/*.aar
|
||||||
|
go_backend/*.jar
|
||||||
|
go_backend/*.exe
|
||||||
|
go_backend/*.xcframework/
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android/.gradle/
|
||||||
|
android/app/libs/gobackend.aar
|
||||||
|
android/local.properties
|
||||||
|
android/*.iml
|
||||||
|
android/key.properties
|
||||||
|
android/*.jks
|
||||||
|
android/*.keystore
|
||||||
|
android/app/*.jks
|
||||||
|
|
||||||
|
# iOS
|
||||||
|
ios/Frameworks/
|
||||||
|
ios/Pods/
|
||||||
|
ios/.symlinks/
|
||||||
|
ios/Flutter/Flutter.framework/
|
||||||
|
ios/Flutter/Flutter.podspec
|
||||||
|
android/app/libs/gobackend-sources.jar
|
||||||
|
|||||||
@@ -0,0 +1,800 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- Now properly converts milliseconds to seconds before display
|
||||||
|
- **Audio Quality from File**: Quality info (bit depth/sample rate) now read from actual FLAC file instead of trusting API
|
||||||
|
- More accurate quality display for all services (Tidal, Qobuz, Amazon)
|
||||||
|
- Also reads quality from existing files when skipping duplicates
|
||||||
|
- **Artist Verification for Downloads**: Added artist name verification to prevent downloading wrong tracks
|
||||||
|
- Verifies artist matches between Spotify metadata and streaming service
|
||||||
|
- Handles different scripts (Japanese/Chinese vs Latin) as same artist with different transliteration
|
||||||
|
- Applied to Tidal, Qobuz, and Amazon downloads
|
||||||
|
- **Metadata Case-Sensitivity**: Fixed FLAC metadata not being properly overwritten when downloaded file has lowercase tags
|
||||||
|
- Now uses case-insensitive comparison when replacing existing Vorbis comments
|
||||||
|
- Fixes issue where Amazon downloads could have duplicate metadata tags
|
||||||
|
- **Settings Navigation Freeze**: Fixed app freezing when navigating back from settings sub-menus on some devices
|
||||||
|
- Added proper PopScope handling for predictive back gesture on Android 14+
|
||||||
|
|
||||||
|
## [2.0.5] - 2026-01-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
## [2.0.4] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
|
||||||
|
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
|
||||||
|
- Shows explanation dialog before opening system settings
|
||||||
|
|
||||||
|
## [2.0.3] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- Material Expressive 3 bottom sheet UI for entering credentials
|
||||||
|
- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results
|
||||||
|
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
||||||
|
- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen
|
||||||
|
- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **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")
|
||||||
|
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
|
||||||
|
- Tertiary color highlight for Hi-Res (24-bit) downloads
|
||||||
|
- **Quality Disclaimer**: Added note in quality picker explaining that actual quality depends on track availability
|
||||||
|
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
- Tidal API v2 response provides exact quality info
|
||||||
|
- Qobuz uses track metadata for quality info
|
||||||
|
- Amazon now reads quality from downloaded FLAC file (previously returned unknown)
|
||||||
|
|
||||||
|
## [2.0.1] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
||||||
|
- Tap to expand long track titles
|
||||||
|
- Expand icon only shows when title is truncated
|
||||||
|
- Ripple effect follows rounded corners including drag handle
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
|
||||||
|
- All downloads now use item-based progress tracking
|
||||||
|
- Fixes duplicate notification bug when finalizing
|
||||||
|
- Cleaner codebase with single progress system
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
|
||||||
|
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
|
||||||
|
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
|
||||||
|
- Container with `primaryContainer` background for each option
|
||||||
|
- Distinct icons: music_note (Lossless), high_quality (Hi-Res), four_k (Max)
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Artist Search Results**: Search now shows artists alongside tracks
|
||||||
|
- Horizontal scrollable artist cards with circular avatars
|
||||||
|
- Tap artist to view their discography
|
||||||
|
- **Multi-Layer Caching System**: Aggressive caching to minimize API calls
|
||||||
|
- Go backend cache: Artist (10 min), Album (10 min), Search (5 min)
|
||||||
|
- Flutter memory cache: Instant navigation for previously viewed artists/albums
|
||||||
|
- Duplicate search prevention: Same query won't trigger new API call
|
||||||
|
- **Real-time Download Status**: Track items show live download progress
|
||||||
|
- Queued: Hourglass icon
|
||||||
|
- Downloading: Circular progress with percentage
|
||||||
|
- Completed: Check icon
|
||||||
|
- Works in Home search, Album, and Playlist screens
|
||||||
|
- **Downloaded Track Indicator**: Tracks already in history show check mark
|
||||||
|
- Lazy file verification: Only checks file existence when tapped
|
||||||
|
- Auto-removes from history if file was deleted, allowing re-download
|
||||||
|
- Prevents accidental duplicate downloads
|
||||||
|
- **Pre-release Support**: GitHub Actions auto-detects preview/beta/rc/alpha tags
|
||||||
|
- Stable users won't receive update notifications for preview versions
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
|
||||||
|
- Header (name, cover) shows instantly from available data
|
||||||
|
- Content (albums/tracks) loads in background inside the screen
|
||||||
|
- Second visit to same artist/album is instant from Flutter cache
|
||||||
|
- **Search Results UI Redesign**:
|
||||||
|
- Removed "Download All" button from search results
|
||||||
|
- Added "Songs" section header (matches "Artists" header style)
|
||||||
|
- Track list now in grouped card with rounded corners (like Settings)
|
||||||
|
- Track items with dividers and InkWell ripple effect
|
||||||
|
- **Larger UI Elements**: Improved touch targets and visual hierarchy
|
||||||
|
- Recent downloads: Album art 56→100px, section height 80→130px
|
||||||
|
- Artist cards: Avatar 72→88px, container 90→100px
|
||||||
|
- Track items: Album art 48→56px
|
||||||
|
- **Optimized Search**: Pressing Enter with same query no longer triggers duplicate search
|
||||||
|
- **Smoother Progress Animation**: Progress jumps to 100% after download completes
|
||||||
|
- Embedding (cover, metadata, lyrics) happens in background without blocking UI
|
||||||
|
- **Finalizing Status**: Shows "Finalizing" indicator while embedding metadata
|
||||||
|
- Distinct icon (edit_note) with tertiary color
|
||||||
|
- User knows download is complete, just processing metadata
|
||||||
|
- **Consistent Download Button Sizes**: All download/status buttons now 44x44px
|
||||||
|
- **Better Dynamic Color Contrast**: Improved visibility for cards and chips with dynamic color
|
||||||
|
- Settings cards use overlay colors for better contrast
|
||||||
|
- Theme/view mode chips have visible borders in light mode
|
||||||
|
- **Navigation Bar Styling**: Distinct background color from content area
|
||||||
|
- **Ask Before Download Default**: Now enabled by default for better UX
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
|
||||||
|
- Uses Riverpod `select()` for granular state watching
|
||||||
|
- Prevents entire list rebuild on progress updates
|
||||||
|
- **Update Notification Stuck**: Fixed notification staying at 100% after download complete
|
||||||
|
|
||||||
|
## [1.6.3] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- Collapsing header with cover art and gradient overlay
|
||||||
|
- Card-based info section with rounded corners (20px radius)
|
||||||
|
- Tonal download buttons with circular shape
|
||||||
|
- Quality picker bottom sheet with drag handle
|
||||||
|
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
|
||||||
|
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
|
||||||
|
- Enables native predictive back gesture animations
|
||||||
|
- Search results stay on Home tab for quick downloads
|
||||||
|
- **Simplified State Management**: Removed `previousState` chain from TrackProvider since Navigator handles back navigation
|
||||||
|
|
||||||
|
## [1.6.2] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
||||||
|
- **Debounced URL Fetches**: All network requests now debounced to prevent rapid duplicate calls
|
||||||
|
- **Bounded File Cache**: File existence cache now limited to 500 entries to prevent memory leak
|
||||||
|
- **Timer Cleanup**: Progress polling timer properly disposed when provider is destroyed
|
||||||
|
- **Stream Error Handling**: Share intent stream now has proper error handling
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Background Download Service**: Downloads now continue running when app is in background
|
||||||
|
- Foreground service with wake lock prevents Android from killing downloads
|
||||||
|
- Persistent notification shows download progress
|
||||||
|
- No more "connection abort" errors when switching apps
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
|
||||||
|
- Changed launch mode to `singleTask` to reuse existing activity instead of restarting
|
||||||
|
- Added `onNewIntent` handler to properly receive new share intents
|
||||||
|
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Manual Quality Selection**: New option to choose audio quality before each download
|
||||||
|
- Toggle "Ask Before Download" in Download Settings
|
||||||
|
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
|
||||||
|
- Works for both single track and batch downloads
|
||||||
|
- **Live Search**: Search results appear as you type with 400ms debounce
|
||||||
|
- Animated search bar moves from center to top when typing
|
||||||
|
- Keyboard stays open during transition
|
||||||
|
- Back button navigates through search history (album → artist → idle)
|
||||||
|
- Clear button to reset search
|
||||||
|
- URLs still require manual submit
|
||||||
|
- **Search Tab Header**: Added collapsing app bar to centered search view for consistent UI across all tabs
|
||||||
|
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
|
||||||
|
- Users on hotfix versions now properly receive update notifications
|
||||||
|
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
|
||||||
|
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
|
||||||
|
- Items in same group are connected with rounded card container
|
||||||
|
- Section headers outside cards for clear visual hierarchy
|
||||||
|
- Better contrast with white overlay for dark mode dynamic colors
|
||||||
|
- **Larger Tab Titles**: Increased app bar title size (28px) and height (130px) for better visibility
|
||||||
|
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
||||||
|
- **Dependencies Updated**:
|
||||||
|
- `share_plus`: 10.1.4 → 12.0.1
|
||||||
|
- `flutter_local_notifications`: 18.0.1 → 19.0.0
|
||||||
|
- `build_runner`: 2.4.15 → 2.10.4
|
||||||
|
|
||||||
|
## [1.5.5] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
|
||||||
|
- Supports track, album, playlist, and artist URLs
|
||||||
|
- Auto-fetches metadata when link is shared
|
||||||
|
- Works with both `open.spotify.com` URLs and `spotify:` URIs
|
||||||
|
- **Lyrics Viewer**: View lyrics for downloaded tracks in Track Metadata screen
|
||||||
|
- Fetches lyrics from LRCLIB on-demand
|
||||||
|
- Clean display without timestamps
|
||||||
|
- Copy lyrics to clipboard
|
||||||
|
- **Artist URL Support**: Paste artist URL to browse their discography
|
||||||
|
- Shows all albums, singles, and compilations
|
||||||
|
- Horizontal scrollable album cards grouped by type
|
||||||
|
- Tap any album to view and download its tracks
|
||||||
|
- **Folder Organization**: Organize downloads into folders by artist or album
|
||||||
|
- Options: None, By Artist, By Album, By Artist & Album
|
||||||
|
- Configurable in Settings > Download
|
||||||
|
- **Japanese Lyrics to Romaji**: Auto-convert Hiragana/Katakana lyrics to romaji
|
||||||
|
- Useful for non-Japanese speakers who want to sing along
|
||||||
|
- Toggle in Settings > Options > Lyrics
|
||||||
|
- Kanji characters are preserved (requires dictionary lookup)
|
||||||
|
- **History View Mode**: Choose between grid or list view for download history
|
||||||
|
- Grid view shows album art in a 3-column layout (default)
|
||||||
|
- List view shows detailed track info with date
|
||||||
|
- Configurable in Settings > Appearance > Layout
|
||||||
|
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
|
||||||
|
- Shows download queue at top when active
|
||||||
|
- Completed downloads auto-move to history section
|
||||||
|
- Cleaner separation between active downloads and history
|
||||||
|
- **Smarter Back Navigation**: Back button now navigates properly
|
||||||
|
- Goes back through search history (album → artist → empty)
|
||||||
|
- Returns to Search tab from other tabs
|
||||||
|
- Only shows exit dialog when truly at root
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
|
||||||
|
|
||||||
|
### 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 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
|
||||||
|
- **Back Navigation**: Android back button now works as expected - returns to previous view (album → artist → empty search)
|
||||||
|
|
||||||
|
## [1.5.0-hotfix6] - 2026-01-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
|
||||||
|
|
||||||
|
## [1.5.0-hotfix5] - 2026-01-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **App Signing**: Use key.properties as per Flutter official documentation
|
||||||
|
|
||||||
|
## [1.5.0-hotfix4] - 2026-01-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **App Signing**: Create keystore.properties in workflow for Gradle
|
||||||
|
|
||||||
|
## [1.5.0-hotfix] - 2026-01-02
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
- **In-App Update**: Download and install updates directly from the app
|
||||||
|
- Progress bar shows download status
|
||||||
|
- Automatic device architecture detection (arm64/arm32)
|
||||||
|
- Downloads correct APK for your device
|
||||||
|
- **Consistent App Signing**: All future releases will use the same signing key
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Update Checker**: Now downloads APK directly instead of opening browser
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
|
||||||
|
- Progress bar in notification during download
|
||||||
|
- Completion notification when track finishes
|
||||||
|
- Summary notification when all downloads complete
|
||||||
|
- **Notification Permission in Setup**: Android 13+ users will be prompted for notification permission during initial setup
|
||||||
|
- New step in setup wizard for notification permission
|
||||||
|
- Option to skip if user doesn't want notifications
|
||||||
|
- **Per-Item Queue Controls**: Each track in download queue now has individual controls
|
||||||
|
- Cancel button for queued items
|
||||||
|
- Stop button for currently downloading items
|
||||||
|
- Retry and Remove buttons for failed/skipped items
|
||||||
|
- Visual progress bar with percentage for each downloading track
|
||||||
|
- **Pull-to-Refresh on Home**: Swipe down to clear URL input and fetched tracks
|
||||||
|
- No need to exit app to clear current search/fetch
|
||||||
|
- **Multi-Progress Tracking for Concurrent Downloads**: Each concurrent download now shows individual progress percentage
|
||||||
|
- Previously concurrent downloads jumped from 0% to 100%
|
||||||
|
- Now each track shows real-time progress when downloading in parallel
|
||||||
|
- **In-App Update**: Download and install updates directly from the app
|
||||||
|
- Progress bar shows download status
|
||||||
|
- Automatic device architecture detection (arm64/arm32)
|
||||||
|
- Downloads correct APK for your device
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
|
||||||
|
- **Queue UI Redesign**: Card-based layout with clearer status indicators
|
||||||
|
- Removed global pause/resume in favor of per-item controls
|
||||||
|
- Better visual hierarchy with cover art, track info, and action buttons
|
||||||
|
- **Settings UI**: Redesigned with category-based navigation (One UI style)
|
||||||
|
- Main settings tab with 4 categories: Appearance, Download, Options, About
|
||||||
|
- Each category opens a detail page
|
||||||
|
- Large title at top with menu items below
|
||||||
|
- One-handed friendly layout
|
||||||
|
- **Collapsing Toolbar**: Implemented One UI style collapsing header for all tabs
|
||||||
|
- Title animates from 28px (expanded) to 20px (collapsed)
|
||||||
|
- Back button only on settings detail pages
|
||||||
|
- Consistent across Home, Downloads, and Settings tabs
|
||||||
|
- **Home Search Bar Redesign**: More prominent and user-friendly input
|
||||||
|
- Larger card-style search bar with border outline
|
||||||
|
- Tap to open bottom sheet with full input experience
|
||||||
|
- Paste and Search buttons clearly visible
|
||||||
|
- Helper text showing supported URL types
|
||||||
|
- **Empty State Improved**: Better onboarding for new users
|
||||||
|
- "Ready to Download" title with icon
|
||||||
|
- Clear instructions on how to use the app
|
||||||
|
- "Add Music" button for quick access
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Added `flutter_local_notifications` package for notifications
|
||||||
|
- Added notification permission request in setup screen for Android 13+
|
||||||
|
- Enabled core library desugaring for all Android subprojects
|
||||||
|
- Added multi-progress tracking in Go backend (`ItemProgress`, `ItemProgressWriter`)
|
||||||
|
- Added `GetAllDownloadProgress`, `InitItemProgress`, `FinishItemProgress`, `ClearItemProgress` exports
|
||||||
|
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
|
||||||
|
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
|
||||||
|
- Optimized state management: Use `select()` to only rebuild when specific state changes
|
||||||
|
- Smoother animations: Changed to `BouncingScrollPhysics` and `Curves.easeOutCubic`
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-01-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
|
||||||
|
- Material Expressive 3 design with cover art header and gradient
|
||||||
|
- Hero animation from list to detail view
|
||||||
|
- Displays: track name, artist, album artist, album, track number, disc number, duration, release date, ISRC, Spotify ID, quality, service, download date
|
||||||
|
- File info: format (FLAC/M4A), file size, quality badge, service badge with colors
|
||||||
|
- Tap to copy ISRC and Spotify ID
|
||||||
|
- "Open in Spotify" button to open track in Spotify app/browser
|
||||||
|
- File path display with copy functionality
|
||||||
|
- Play and Delete action buttons
|
||||||
|
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
|
||||||
|
- Play button still available for quick playback
|
||||||
|
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
|
||||||
|
- Removed unused `history_screen.dart` and `history_tab.dart` files
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-01-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Update Checker**: Automatic check for new versions from GitHub releases
|
||||||
|
- Shows changelog in update dialog
|
||||||
|
- Option to disable update notifications
|
||||||
|
- **Release Changelog**: GitHub releases now include full changelog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated version to 1.1.2
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-01-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **About Dialog**: Custom About dialog with cleaner layout
|
||||||
|
- **Setup Screen**: Fixed step indicator line alignment
|
||||||
|
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
|
||||||
|
- **Copyright Year**: Updated to 2026
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed Theme Preview from Settings
|
||||||
|
- Added MIT License
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-01-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
||||||
|
- Default: Sequential (1 at a time) for stability
|
||||||
|
- Options: 1, 2, or 3 concurrent downloads
|
||||||
|
- Warning about potential rate limiting from streaming services
|
||||||
|
- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal
|
||||||
|
- **History Persistence**: Download history now persists across app restarts using SharedPreferences
|
||||||
|
- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads
|
||||||
|
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **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`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated version to 1.1.0
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
||||||
|
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
||||||
|
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
||||||
|
- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()`
|
||||||
|
- Added shared `http.Transport` with connection pooling in `httputil.go`
|
||||||
|
- Added `CleanupConnections()` export for Flutter to call via method channel
|
||||||
|
|
||||||
|
## [1.0.5] - Previous Release
|
||||||
|
|
||||||
|
- Material Expressive 3 UI
|
||||||
|
- Dynamic color support
|
||||||
|
- Swipe navigation with PageView
|
||||||
|
- Settings as bottom navigation tab
|
||||||
|
- APK size optimization
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 zarzet
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/ca16289599f71b8e50d3726a8c64a202ea922a1893bcf21b9eca1a050736f1f5/)
|
||||||

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

|

|
||||||
@@ -13,17 +14,43 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
|
||||||
## Screenshot
|
## Screenshots
|
||||||
|
|
||||||
<!--  -->
|
<p align="center">
|
||||||
|
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||||
|
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||||
|
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||||
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Metadata Source
|
||||||
|
|
||||||
|
SpotiFLAC supports two metadata sources for searching tracks:
|
||||||
|
|
||||||
|
| Source | Pros | Cons |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
|
||||||
|
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
|
||||||
|
|
||||||
|
### Using Spotify
|
||||||
|
To use Spotify as your search source without hitting rate limits:
|
||||||
|
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
|
||||||
|
2. Create an app to get your Client ID and Client Secret
|
||||||
|
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
|
||||||
|
4. Enter your Client ID and Secret
|
||||||
|
5. Change **Search Source** to Spotify
|
||||||
|
|
||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||||
|
|
||||||
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
|
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +8,13 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load keystore properties for local builds
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.zarz.spotiflac"
|
namespace = "com.zarz.spotiflac"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -22,6 +32,17 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
@@ -30,8 +51,6 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
// Only include arm64-v8a for smaller APK (most modern devices)
|
|
||||||
// Remove this line if you need to support older 32-bit devices
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
}
|
}
|
||||||
@@ -39,8 +58,13 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// For local builds: use release signing if key.properties exists
|
||||||
// Enable code shrinking and resource shrinking
|
// For CI builds: APK is signed by GitHub Action after build
|
||||||
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@@ -72,8 +96,11 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
implementation(files("libs/gobackend.aar"))
|
|
||||||
|
// Include all AAR and JAR files from libs folder
|
||||||
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-keep class io.flutter.plugins.** { *; }
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.**
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.**
|
||||||
|
-dontwarn com.google.android.play.core.tasks.**
|
||||||
|
|
||||||
|
# Ignore missing javax.xml.stream (not used on Android)
|
||||||
|
-dontwarn javax.xml.stream.**
|
||||||
|
|
||||||
# Go backend (gobackend.aar)
|
# Go backend (gobackend.aar)
|
||||||
-keep class gobackend.** { *; }
|
-keep class gobackend.** { *; }
|
||||||
-keep class go.** { *; }
|
-keep class go.** { *; }
|
||||||
@@ -14,6 +22,9 @@
|
|||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-keep class com.arthenica.smartexception.** { *; }
|
||||||
|
|
||||||
|
# Apache Tika (if used by FFmpeg)
|
||||||
|
-dontwarn org.apache.tika.**
|
||||||
|
|
||||||
# Keep native methods
|
# Keep native methods
|
||||||
-keepclasseswithmembernames class * {
|
-keepclasseswithmembernames class * {
|
||||||
native <methods>;
|
native <methods>;
|
||||||
|
|||||||
@@ -4,26 +4,30 @@
|
|||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
<!-- For Android 11+ (API 30-32) - full storage access -->
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="SpotiFLAC"
|
android:label="SpotiFLAC"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:taskAffinity=""
|
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
@@ -76,6 +80,17 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- FileProvider for APK installation -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileProvider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
|
|||||||
@@ -5,83 +5,211 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service to keep downloads running when app is in background.
|
||||||
|
* This prevents Android from killing the download process or throttling network.
|
||||||
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "spotiflac_download_channel"
|
private const val CHANNEL_ID = "download_channel"
|
||||||
const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1001
|
||||||
const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD"
|
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
|
||||||
const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD"
|
|
||||||
|
const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD"
|
||||||
|
const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD"
|
||||||
|
const val ACTION_UPDATE_PROGRESS = "com.zarz.spotiflac.action.UPDATE_PROGRESS"
|
||||||
|
|
||||||
|
const val EXTRA_TRACK_NAME = "track_name"
|
||||||
|
const val EXTRA_ARTIST_NAME = "artist_name"
|
||||||
|
const val EXTRA_PROGRESS = "progress"
|
||||||
|
const val EXTRA_TOTAL = "total"
|
||||||
|
const val EXTRA_QUEUE_COUNT = "queue_count"
|
||||||
|
|
||||||
|
private var isRunning = false
|
||||||
|
|
||||||
|
fun isServiceRunning(): Boolean = isRunning
|
||||||
|
|
||||||
|
fun start(context: Context, trackName: String = "", artistName: String = "", queueCount: Int = 0) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_START
|
||||||
|
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||||
|
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||||
|
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_STOP
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProgress(context: Context, trackName: String, artistName: String, progress: Long, total: Long, queueCount: Int) {
|
||||||
|
val intent = Intent(context, DownloadService::class.java).apply {
|
||||||
|
action = ACTION_UPDATE_PROGRESS
|
||||||
|
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||||
|
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||||
|
putExtra(EXTRA_PROGRESS, progress)
|
||||||
|
putExtra(EXTRA_TOTAL, total)
|
||||||
|
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var currentTrackName = ""
|
||||||
|
private var currentArtistName = ""
|
||||||
|
private var queueCount = 0
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_START -> startForegroundService()
|
ACTION_START -> {
|
||||||
ACTION_STOP -> stopSelf()
|
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: ""
|
||||||
|
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: ""
|
||||||
|
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, 0)
|
||||||
|
startForegroundService()
|
||||||
|
}
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopForegroundService()
|
||||||
|
}
|
||||||
|
ACTION_UPDATE_PROGRESS -> {
|
||||||
|
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: currentTrackName
|
||||||
|
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: currentArtistName
|
||||||
|
val progress = intent.getLongExtra(EXTRA_PROGRESS, 0)
|
||||||
|
val total = intent.getLongExtra(EXTRA_TOTAL, 0)
|
||||||
|
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||||
|
updateNotification(progress, total)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Download Progress",
|
"Download Service",
|
||||||
NotificationManager.IMPORTANCE_LOW
|
NotificationManager.IMPORTANCE_LOW
|
||||||
).apply {
|
).apply {
|
||||||
description = "Shows download progress for SpotiFLAC"
|
description = "Shows download progress"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
val notification = createNotification("Downloading...", 0)
|
isRunning = true
|
||||||
|
|
||||||
|
// Acquire wake lock to prevent CPU sleep
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = powerManager.newWakeLock(
|
||||||
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
WAKELOCK_TAG
|
||||||
|
).apply {
|
||||||
|
acquire(60 * 60 * 1000L) // 1 hour max
|
||||||
|
}
|
||||||
|
|
||||||
|
val notification = buildNotification(0, 0)
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProgress(trackName: String, progress: Int) {
|
private fun stopForegroundService() {
|
||||||
val notification = createNotification(trackName, progress)
|
isRunning = false
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(progress: Long, total: Long) {
|
||||||
|
if (!isRunning) return
|
||||||
|
|
||||||
|
val notification = buildNotification(progress, total)
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.notify(NOTIFICATION_ID, notification)
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotification(title: String, progress: Int): Notification {
|
private fun buildNotification(progress: Long, total: Long): Notification {
|
||||||
val intent = Intent(this, MainActivity::class.java)
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
this, 0, intent,
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
val stopIntent = Intent(this, DownloadService::class.java).apply {
|
val title = if (queueCount > 1) {
|
||||||
action = ACTION_STOP
|
"Downloading $queueCount tracks"
|
||||||
|
} else if (currentTrackName.isNotEmpty()) {
|
||||||
|
currentTrackName
|
||||||
|
} else {
|
||||||
|
"Downloading..."
|
||||||
}
|
}
|
||||||
val stopPendingIntent = PendingIntent.getService(
|
|
||||||
this, 0, stopIntent,
|
val text = if (currentArtistName.isNotEmpty() && queueCount <= 1) {
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
currentArtistName
|
||||||
)
|
} else if (total > 0) {
|
||||||
|
val progressPercent = (progress * 100 / total).toInt()
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
val progressMB = progress / (1024.0 * 1024.0)
|
||||||
.setContentTitle("SpotiFLAC")
|
val totalMB = total / (1024.0 * 1024.0)
|
||||||
.setContentText(title)
|
String.format("%.1f / %.1f MB (%d%%)", progressMB, totalMB, progressPercent)
|
||||||
|
} else {
|
||||||
|
"Preparing download..."
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
.setProgress(100, progress, progress == 0)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent)
|
.setOngoing(true)
|
||||||
.build()
|
.setOnlyAlertOnce(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
builder.setProgress(100, (progress * 100 / total).toInt(), false)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
isRunning = false
|
||||||
|
wakeLock?.let {
|
||||||
|
if (it.isHeld) {
|
||||||
|
it.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.zarz.spotiflac
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import gobackend.Gobackend
|
import gobackend.Gobackend
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -12,8 +15,15 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||||
|
private val FFMPEG_CHANNEL = "com.zarz.spotiflac/ffmpeg"
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
// Update the intent so receive_sharing_intent can access the new data
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
@@ -43,6 +53,15 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"searchSpotifyAll" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
|
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"checkAvailability" -> {
|
"checkAvailability" -> {
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
@@ -71,6 +90,33 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"getAllDownloadProgress" -> {
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getAllDownloadProgress()
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"initItemProgress" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.initItemProgress(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"finishItemProgress" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.finishItemProgress(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"clearItemProgress" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearItemProgress(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"setDownloadDirectory" -> {
|
"setDownloadDirectory" -> {
|
||||||
val path = call.argument<String>("path") ?: ""
|
val path = call.argument<String>("path") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -114,8 +160,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName)
|
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -127,6 +174,109 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"cleanupConnections" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.cleanupConnections()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"startDownloadService" -> {
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||||
|
DownloadService.start(this@MainActivity, trackName, artistName, queueCount)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"stopDownloadService" -> {
|
||||||
|
DownloadService.stop(this@MainActivity)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"updateDownloadServiceProgress" -> {
|
||||||
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val progress = call.argument<Long>("progress") ?: 0L
|
||||||
|
val total = call.argument<Long>("total") ?: 0L
|
||||||
|
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||||
|
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"isDownloadServiceRunning" -> {
|
||||||
|
result.success(DownloadService.isServiceRunning())
|
||||||
|
}
|
||||||
|
"setSpotifyCredentials" -> {
|
||||||
|
val clientId = call.argument<String>("client_id") ?: ""
|
||||||
|
val clientSecret = call.argument<String>("client_secret") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"preWarmTrackCache" -> {
|
||||||
|
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.preWarmTrackCacheJSON(tracksJson)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getTrackCacheSize" -> {
|
||||||
|
val size = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getTrackCacheSize()
|
||||||
|
}
|
||||||
|
result.success(size.toInt())
|
||||||
|
}
|
||||||
|
"clearTrackCache" -> {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.clearTrackIDCache()
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
// Deezer API methods
|
||||||
|
"searchDeezerAll" -> {
|
||||||
|
val query = call.argument<String>("query") ?: ""
|
||||||
|
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||||
|
val artistLimit = call.argument<Int>("artist_limit") ?: 3
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong())
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getDeezerMetadata" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getDeezerMetadata(resourceType, resourceId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"parseDeezerUrl" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.parseDeezerURLExport(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"searchDeezerByISRC" -> {
|
||||||
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.searchDeezerByISRC(isrc)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"convertSpotifyToDeezer" -> {
|
||||||
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.convertSpotifyToDeezer(resourceType, spotifyId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"getSpotifyMetadataWithFallback" -> {
|
||||||
|
val url = call.argument<String>("url") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -134,5 +284,37 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FFmpeg method channel
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FFMPEG_CHANNEL).setMethodCallHandler { call, result ->
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
when (call.method) {
|
||||||
|
"execute" -> {
|
||||||
|
val command = call.argument<String>("command") ?: ""
|
||||||
|
val session = withContext(Dispatchers.IO) {
|
||||||
|
FFmpegKit.execute(command)
|
||||||
|
}
|
||||||
|
val returnCode = session.returnCode
|
||||||
|
val output = session.output ?: ""
|
||||||
|
result.success(mapOf(
|
||||||
|
"success" to ReturnCode.isSuccess(returnCode),
|
||||||
|
"returnCode" to (returnCode?.value ?: -1),
|
||||||
|
"output" to output
|
||||||
|
))
|
||||||
|
}
|
||||||
|
"getVersion" -> {
|
||||||
|
val session = withContext(Dispatchers.IO) {
|
||||||
|
FFmpegKit.execute("-version")
|
||||||
|
}
|
||||||
|
result.success(session.output ?: "unknown")
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("FFMPEG_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 651 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<external-files-path name="external_files" path="." />
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
<files-path name="files" path="." />
|
||||||
|
</paths>
|
||||||
@@ -10,10 +10,19 @@ subprojects {
|
|||||||
if (project.hasProperty("android")) {
|
if (project.hasProperty("android")) {
|
||||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable multidex for all subprojects
|
||||||
|
defaultConfig {
|
||||||
|
multiDexEnabled = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add desugaring dependency to all Android subprojects
|
||||||
|
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,208 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
||||||
|
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('FFmpeg');
|
||||||
|
|
||||||
|
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
||||||
|
class FFmpegServiceIOS {
|
||||||
|
/// Execute FFmpeg command and return result
|
||||||
|
static Future<FFmpegResultIOS> _execute(String command) async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute(command);
|
||||||
|
final returnCode = await session.getReturnCode();
|
||||||
|
final output = await session.getOutput() ?? '';
|
||||||
|
return FFmpegResultIOS(
|
||||||
|
success: ReturnCode.isSuccess(returnCode),
|
||||||
|
returnCode: returnCode?.getValue() ?? -1,
|
||||||
|
output: output,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('FFmpeg execute error: $e');
|
||||||
|
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert M4A (DASH segments) to FLAC
|
||||||
|
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||||
|
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
||||||
|
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(inputPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FLAC to MP3
|
||||||
|
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
||||||
|
final dir = File(inputPath).parent.path;
|
||||||
|
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
|
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
||||||
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
||||||
|
|
||||||
|
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) return outputPath;
|
||||||
|
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FLAC to M4A
|
||||||
|
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
||||||
|
final dir = File(inputPath).parent.path;
|
||||||
|
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
||||||
|
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
||||||
|
await Directory(outputDir).create(recursive: true);
|
||||||
|
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
||||||
|
|
||||||
|
String command;
|
||||||
|
if (codec == 'alac') {
|
||||||
|
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
|
} else {
|
||||||
|
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
if (result.success) return outputPath;
|
||||||
|
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embed cover art to FLAC file
|
||||||
|
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after cover embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) await tempFile.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embed metadata and cover art to FLAC file
|
||||||
|
/// Returns the file path on success, null on failure
|
||||||
|
static Future<String?> embedMetadata({
|
||||||
|
required String flacPath,
|
||||||
|
String? coverPath,
|
||||||
|
Map<String, String>? metadata,
|
||||||
|
}) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
|
||||||
|
// Construct command
|
||||||
|
final StringBuffer cmdBuffer = StringBuffer();
|
||||||
|
cmdBuffer.write('-i "$flacPath" ');
|
||||||
|
|
||||||
|
// Add cover input if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-i "$coverPath" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map audio stream
|
||||||
|
cmdBuffer.write('-map 0:a ');
|
||||||
|
|
||||||
|
// Map cover stream if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-map 1:0 ');
|
||||||
|
cmdBuffer.write('-c:v copy ');
|
||||||
|
cmdBuffer.write('-disposition:v attached_pic ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy audio codec (don't re-encode)
|
||||||
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
|
// Add text metadata
|
||||||
|
if (metadata != null) {
|
||||||
|
metadata.forEach((key, value) {
|
||||||
|
// Sanitize value: escape double quotes
|
||||||
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
|
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('"$tempOutput" -y');
|
||||||
|
|
||||||
|
final command = cmdBuffer.toString();
|
||||||
|
_log.d('Executing FFmpeg command: $command');
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after metadata embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file if exists
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if FFmpeg is available
|
||||||
|
static Future<bool> isAvailable() async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute('-version');
|
||||||
|
final returnCode = await session.getReturnCode();
|
||||||
|
return ReturnCode.isSuccess(returnCode);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get FFmpeg version info
|
||||||
|
static Future<String?> getVersion() async {
|
||||||
|
try {
|
||||||
|
final session = await FFmpegKit.execute('-version');
|
||||||
|
return await session.getOutput();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FFmpegResultIOS {
|
||||||
|
final bool success;
|
||||||
|
final int returnCode;
|
||||||
|
final String output;
|
||||||
|
|
||||||
|
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,15 +11,26 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||||
type AmazonDownloader struct {
|
type AmazonDownloader struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
regions []string // us, eu regions for DoubleDouble service
|
regions []string // us, eu regions for DoubleDouble service
|
||||||
|
lastAPICallTime time.Time // Rate limiting: track last API call
|
||||||
|
apiCallCount int // Rate limiting: counter per minute
|
||||||
|
apiCallResetTime time.Time // Rate limiting: reset time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Global Amazon downloader instance for connection reuse
|
||||||
|
globalAmazonDownloader *AmazonDownloader
|
||||||
|
amazonDownloaderOnce sync.Once
|
||||||
|
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
|
||||||
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||||
type DoubleDoubleSubmitResponse struct {
|
type DoubleDoubleSubmitResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
@@ -36,12 +48,114 @@ type DoubleDoubleStatusResponse struct {
|
|||||||
} `json:"current"`
|
} `json:"current"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
// amazonArtistsMatch checks if the artist names are similar enough
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
return &AmazonDownloader{
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
|
if expectedFirst == foundFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func amazonIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||||
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
|
amazonDownloaderOnce.Do(func() {
|
||||||
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
|
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||||
|
regions: []string{"us", "eu"}, // Same regions as PC
|
||||||
|
apiCallResetTime: time.Now(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalAmazonDownloader
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForRateLimit implements rate limiting similar to PC version
|
||||||
|
// Max 9 requests per minute with 7 second delay between requests
|
||||||
|
func (a *AmazonDownloader) waitForRateLimit() {
|
||||||
|
amazonRateLimitMu.Lock()
|
||||||
|
defer amazonRateLimitMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Reset counter every minute
|
||||||
|
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||||
|
a.apiCallCount = 0
|
||||||
|
a.apiCallResetTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've hit the limit (9 requests per minute), wait until next minute
|
||||||
|
if a.apiCallCount >= 9 {
|
||||||
|
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||||
|
if waitTime > 0 {
|
||||||
|
fmt.Printf("[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
|
||||||
|
fmt.Printf("[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
|
||||||
@@ -72,10 +186,13 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||||
|
|
||||||
// Step 1: Submit download request
|
// Step 1: Submit download request with rate limiting
|
||||||
encodedURL := url.QueryEscape(amazonURL)
|
encodedURL := url.QueryEscape(amazonURL)
|
||||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||||
|
|
||||||
|
// Apply rate limiting before request (like PC version)
|
||||||
|
a.waitForRateLimit()
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", submitURL, nil)
|
req, err := http.NewRequest("GET", submitURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -85,15 +202,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
|
||||||
|
fmt.Printf("[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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,11 +347,12 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
|
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
if itemID != "" {
|
||||||
SetDownloading(true)
|
StartItemProgress(itemID)
|
||||||
defer SetDownloading(false)
|
defer CompleteItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,62 +371,129 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Track download progress
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
pw := NewProgressWriter(out)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
_, err = io.Copy(pw, resp.Body)
|
|
||||||
if err != nil {
|
// Use item progress writer with buffered output
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
var written int64
|
||||||
|
if itemID != "" {
|
||||||
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
|
written, err = io.Copy(pw, resp.Body)
|
||||||
|
} else {
|
||||||
|
// Fallback: direct copy without progress tracking
|
||||||
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
// Flush buffer before checking for errors
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size if Content-Length was provided
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmazonDownloadResult contains download result with quality info
|
||||||
|
type AmazonDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
// downloadFromAmazon downloads a track using the request parameters
|
||||||
// Uses DoubleDouble service (same as PC version)
|
// Uses DoubleDouble service (same as PC version)
|
||||||
func downloadFromAmazon(req DownloadRequest) (string, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from SongLink
|
// Get Amazon URL from SongLink
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
var availability *TrackAvailability
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||||
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
|
// Extract Deezer ID and use Deezer-based lookup
|
||||||
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
|
fmt.Printf("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
|
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
|
} else if req.SpotifyID != "" {
|
||||||
|
// Use Spotify ID
|
||||||
|
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
|
} else {
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !availability.Amazon || availability.AmazonURL == "" {
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory if needed
|
// Create output directory if needed
|
||||||
if req.OutputDir != "." {
|
if req.OutputDir != "." {
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download using DoubleDouble service (same as PC)
|
// Download using DoubleDouble service (same as PC)
|
||||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify artist matches
|
||||||
|
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||||
|
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log match found
|
||||||
|
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
// Build filename using Spotify metadata (more accurate)
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
@@ -295,12 +508,37 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return "EXISTS:" + outputPath, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
var parallelResult *ParallelDownloadResult
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for parallel operations to complete
|
||||||
|
<-parallelDone
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
// Log track info from DoubleDouble (for debugging)
|
||||||
@@ -321,43 +559,60 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
|||||||
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)
|
fmt.Printf("[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...")
|
fmt.Printf("[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)
|
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
if lyricsErr != nil {
|
|
||||||
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
|
|
||||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
fmt.Println("[Amazon] No lyrics found for this track")
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||||
lrcContent := convertToLRC(lyrics)
|
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
|
||||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
return outputPath, nil
|
|
||||||
|
// Read actual quality from the downloaded FLAC file
|
||||||
|
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||||
|
quality, err := GetAudioQuality(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
// Return 0 to indicate unknown quality
|
||||||
|
return AmazonDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: 0,
|
||||||
|
SampleRate: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
|
||||||
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
|
return AmazonDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: quality.BitDepth,
|
||||||
|
SampleRate: quality.SampleRate,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: req.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber,
|
||||||
|
}, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +31,12 @@ func ParseSpotifyURL(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||||
|
// Pass empty strings to use default credentials
|
||||||
|
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||||
|
SetSpotifyCredentials(clientID, clientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||||
// Returns JSON with track/album/playlist data
|
// Returns JSON with track/album/playlist data
|
||||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||||
@@ -69,6 +77,26 @@ func SearchSpotify(query string, limit int) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchSpotifyAll searches for tracks and artists on Spotify
|
||||||
|
// Returns JSON with tracks and artists arrays
|
||||||
|
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := NewSpotifyMetadataClient()
|
||||||
|
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// CheckAvailability checks track availability on streaming services
|
// CheckAvailability checks track availability on streaming services
|
||||||
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
||||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||||
@@ -98,12 +126,15 @@ type DownloadRequest struct {
|
|||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
FilenameFormat string `json:"filename_format"`
|
FilenameFormat string `json:"filename_format"`
|
||||||
|
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResponse represents the result of a download
|
// DownloadResponse represents the result of a download
|
||||||
@@ -112,7 +143,32 @@ type DownloadResponse struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
|
// Actual quality info from the source
|
||||||
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
|
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadResult is a generic result type for all downloaders
|
||||||
|
// DownloadResult is a generic result type for all downloaders
|
||||||
|
type DownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadTrack downloads a track from the specified service
|
// DownloadTrack downloads a track from the specified service
|
||||||
@@ -124,16 +180,65 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
var filePath string
|
// Trim whitespace from string fields to prevent filename/path issues
|
||||||
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch req.Service {
|
switch req.Service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
filePath, err = downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
|
if tidalErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: tidalResult.FilePath,
|
||||||
|
BitDepth: tidalResult.BitDepth,
|
||||||
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
Title: tidalResult.Title,
|
||||||
|
Artist: tidalResult.Artist,
|
||||||
|
Album: tidalResult.Album,
|
||||||
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
filePath, err = downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
|
if qobuzErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: qobuzResult.FilePath,
|
||||||
|
BitDepth: qobuzResult.BitDepth,
|
||||||
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
Title: qobuzResult.Title,
|
||||||
|
Artist: qobuzResult.Artist,
|
||||||
|
Album: qobuzResult.Album,
|
||||||
|
ReleaseDate: qobuzResult.ReleaseDate,
|
||||||
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
filePath, err = downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
|
if amazonErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: amazonResult.FilePath,
|
||||||
|
BitDepth: amazonResult.BitDepth,
|
||||||
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
Artist: amazonResult.Artist,
|
||||||
|
Album: amazonResult.Album,
|
||||||
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = amazonErr
|
||||||
default:
|
default:
|
||||||
return errorResponse("Unknown service: " + req.Service)
|
return errorResponse("Unknown service: " + req.Service)
|
||||||
}
|
}
|
||||||
@@ -143,21 +248,50 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Download complete",
|
Message: "Download complete",
|
||||||
FilePath: filePath,
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: req.Service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
@@ -172,13 +306,22 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
return errorResponse("Invalid request: " + err.Error())
|
return errorResponse("Invalid request: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trim whitespace from string fields to prevent filename/path issues
|
||||||
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||||
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||||
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||||
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||||
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
// Build service order starting with preferred service
|
||||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
allServices := []string{"qobuz", "tidal", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
preferredService = "tidal"
|
preferredService = "tidal"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[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 {
|
||||||
@@ -187,40 +330,108 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[DownloadWithFallback] Service order: %v\n", services)
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
|
fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service)
|
||||||
req.Service = service
|
req.Service = service
|
||||||
|
|
||||||
var filePath string
|
var result DownloadResult
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch service {
|
switch service {
|
||||||
case "tidal":
|
case "tidal":
|
||||||
filePath, err = downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
|
if tidalErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: tidalResult.FilePath,
|
||||||
|
BitDepth: tidalResult.BitDepth,
|
||||||
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
Title: tidalResult.Title,
|
||||||
|
Artist: tidalResult.Artist,
|
||||||
|
Album: tidalResult.Album,
|
||||||
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||||
|
}
|
||||||
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
filePath, err = downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
|
if qobuzErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: qobuzResult.FilePath,
|
||||||
|
BitDepth: qobuzResult.BitDepth,
|
||||||
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
|
}
|
||||||
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
filePath, err = downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
|
if amazonErr == nil {
|
||||||
|
result = DownloadResult{
|
||||||
|
FilePath: amazonResult.FilePath,
|
||||||
|
BitDepth: amazonResult.BitDepth,
|
||||||
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
Artist: amazonResult.Artist,
|
||||||
|
Album: amazonResult.Album,
|
||||||
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
|
}
|
||||||
|
err = amazonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if len(filePath) > 7 && filePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
|
actualPath := result.FilePath[7:]
|
||||||
|
// Read actual quality from existing file
|
||||||
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "File already exists",
|
Message: "File already exists",
|
||||||
FilePath: filePath[7:],
|
FilePath: actualPath,
|
||||||
AlreadyExists: true,
|
AlreadyExists: true,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read actual quality from downloaded file (more accurate than API)
|
||||||
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
|
if qErr == nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Downloaded from " + service,
|
Message: "Downloaded from " + service,
|
||||||
FilePath: filePath,
|
FilePath: result.FilePath,
|
||||||
|
ActualBitDepth: result.BitDepth,
|
||||||
|
ActualSampleRate: result.SampleRate,
|
||||||
|
Service: service,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -239,6 +450,33 @@ func GetDownloadProgress() string {
|
|||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
|
||||||
|
func GetAllDownloadProgress() string {
|
||||||
|
return GetMultiProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitItemProgress initializes progress tracking for a download item
|
||||||
|
func InitItemProgress(itemID string) {
|
||||||
|
StartItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishItemProgress marks a download item as complete and removes tracking
|
||||||
|
func FinishItemProgress(itemID string) {
|
||||||
|
CompleteItemProgress(itemID)
|
||||||
|
// Don't remove immediately - let Flutter poll one more time to see 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearItemProgress removes progress tracking for a specific item
|
||||||
|
func ClearItemProgress(itemID string) {
|
||||||
|
RemoveItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupConnections closes idle HTTP connections
|
||||||
|
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||||
|
func CleanupConnections() {
|
||||||
|
CloseIdleConnections()
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -261,6 +499,26 @@ func CheckDuplicate(outputDir, isrc string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckDuplicatesBatch checks multiple files for duplicates in parallel
|
||||||
|
// Uses ISRC index for fast lookup (builds index once, checks all tracks)
|
||||||
|
// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...]
|
||||||
|
// Returns JSON array of results
|
||||||
|
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
|
||||||
|
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{}
|
||||||
@@ -301,15 +559,26 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsLRC fetches lyrics and converts to LRC format string
|
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string) (string, error) {
|
// First tries to extract from file, then falls back to fetching from internet
|
||||||
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
||||||
|
// Try to extract from file first (much faster)
|
||||||
|
if filePath != "" {
|
||||||
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
|
if err == nil && lyrics != "" {
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to fetching from internet
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyrics)
|
// Convert to LRC format with metadata headers (like PC version)
|
||||||
|
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,10 +598,335 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[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, "not found") ||
|
||||||
|
strings.Contains(lowerMsg, "not available") ||
|
||||||
|
strings.Contains(lowerMsg, "no results") ||
|
||||||
|
strings.Contains(lowerMsg, "track not found") ||
|
||||||
|
strings.Contains(lowerMsg, "all services failed") {
|
||||||
|
errorType = "not_found"
|
||||||
|
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||||
|
strings.Contains(lowerMsg, "429") ||
|
||||||
|
strings.Contains(lowerMsg, "too many requests") {
|
||||||
|
errorType = "rate_limit"
|
||||||
|
} else if strings.Contains(lowerMsg, "network") ||
|
||||||
|
strings.Contains(lowerMsg, "connection") ||
|
||||||
|
strings.Contains(lowerMsg, "timeout") ||
|
||||||
|
strings.Contains(lowerMsg, "dial") {
|
||||||
|
errorType = "network"
|
||||||
|
}
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: msg,
|
Error: msg,
|
||||||
|
ErrorType: errorType,
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
func getString(m map[string]interface{}, key string) string {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if v, ok := m[key]; ok {
|
if v, ok := m[key]; ok {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
return s
|
// Trim leading/trailing whitespace to prevent filename issues
|
||||||
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,25 +12,59 @@ import (
|
|||||||
|
|
||||||
// HTTP utility functions for consistent request handling across all downloaders
|
// HTTP utility functions for consistent request handling across all downloaders
|
||||||
|
|
||||||
// User-Agent pool for Android Chrome browsers
|
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||||
var userAgentTemplates = []string{
|
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
||||||
"Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
func getRandomUserAgent() string {
|
||||||
"Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
||||||
"Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
// Some APIs may block mobile User-Agents, so we use desktop format
|
||||||
"Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
||||||
|
|
||||||
|
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||||
|
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
||||||
|
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||||
|
winMajor,
|
||||||
|
chromeVersion,
|
||||||
|
chromeBuild,
|
||||||
|
chromePatch,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRandomUserAgent generates a random browser-like User-Agent string (Android Chrome format)
|
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||||
func getRandomUserAgent() string {
|
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||||
template := userAgentTemplates[rand.Intn(len(userAgentTemplates))]
|
func getRandomMacUserAgent() string {
|
||||||
|
macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||||
|
macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||||
|
webkitMajor := rand.Intn(7) + 530
|
||||||
|
webkitMinor := rand.Intn(7) + 30
|
||||||
|
chromeMajor := rand.Intn(25) + 80
|
||||||
|
chromeBuild := rand.Intn(1500) + 3000
|
||||||
|
chromePatch := rand.Intn(65) + 60
|
||||||
|
safariMajor := rand.Intn(7) + 530
|
||||||
|
safariMinor := rand.Intn(6) + 30
|
||||||
|
|
||||||
androidVersion := rand.Intn(5) + 10 // Android 10-14
|
return fmt.Sprintf(
|
||||||
deviceModel := rand.Intn(900) + 100 // Random model number
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
macMajor,
|
||||||
chromeBuild := rand.Intn(5000) + 5000
|
macMinor,
|
||||||
chromePatch := rand.Intn(200) + 100
|
webkitMajor,
|
||||||
|
webkitMinor,
|
||||||
|
chromeMajor,
|
||||||
|
chromeBuild,
|
||||||
|
chromePatch,
|
||||||
|
safariMajor,
|
||||||
|
safariMinor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(template, androidVersion, deviceModel, chromeVersion, chromeBuild, chromePatch)
|
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||||
|
func getRandomDesktopUserAgent() string {
|
||||||
|
if rand.Intn(2) == 0 {
|
||||||
|
return getRandomUserAgent() // Windows
|
||||||
|
}
|
||||||
|
return getRandomMacUserAgent() // Mac
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default timeout values
|
// Default timeout values
|
||||||
@@ -41,13 +76,63 @@ const (
|
|||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
|
// Optimized for large file downloads (FLAC ~30-50MB)
|
||||||
|
var sharedTransport = &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
MaxConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
||||||
|
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
||||||
|
DisableCompression: true, // FLAC is already compressed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared HTTP client for general requests (reuses connections)
|
||||||
|
var sharedClient = &http.Client{
|
||||||
|
Transport: sharedTransport,
|
||||||
|
Timeout: DefaultTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared HTTP client for downloads (longer timeout, reuses connections)
|
||||||
|
var downloadClient = &http.Client{
|
||||||
|
Transport: sharedTransport,
|
||||||
|
Timeout: DownloadTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||||
|
// Uses shared transport for connection reuse
|
||||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Timeout: timeout,
|
Transport: sharedTransport,
|
||||||
|
Timeout: timeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSharedClient returns the shared HTTP client for general requests
|
||||||
|
func GetSharedClient() *http.Client {
|
||||||
|
return sharedClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDownloadClient returns the shared HTTP client for downloads
|
||||||
|
func GetDownloadClient() *http.Client {
|
||||||
|
return downloadClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseIdleConnections closes idle connections in the shared transport
|
||||||
|
// Call this periodically during large batch downloads to prevent connection buildup
|
||||||
|
func CloseIdleConnections() {
|
||||||
|
sharedTransport.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
|
||||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||||
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())
|
||||||
|
|||||||
@@ -248,6 +248,8 @@ func msToLRCTimestamp(ms int64) string {
|
|||||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||||
|
// Use convertToLRCWithMetadata for full LRC with headers
|
||||||
func convertToLRC(lyrics *LyricsResponse) string {
|
func convertToLRC(lyrics *LyricsResponse) string {
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -272,6 +274,45 @@ func convertToLRC(lyrics *LyricsResponse) string {
|
|||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||||
|
// Includes [ti:], [ar:], [by:] headers
|
||||||
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
// Add metadata headers
|
||||||
|
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
|
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
|
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||||
|
builder.WriteString("\n")
|
||||||
|
|
||||||
|
// Add lyrics lines
|
||||||
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
|
for _, line := range lyrics.Lines {
|
||||||
|
if line.Words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||||
|
builder.WriteString(timestamp)
|
||||||
|
builder.WriteString(line.Words)
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, line := range lyrics.Lines {
|
||||||
|
if line.Words == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
builder.WriteString(line.Words)
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
func simplifyTrackName(name string) string {
|
func simplifyTrackName(name string) string {
|
||||||
patterns := []string{
|
patterns := []string{
|
||||||
`\s*\(feat\..*?\)`,
|
`\s*\(feat\..*?\)`,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
@@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing
|
// Remove existing (case-insensitive comparison for Vorbis comments)
|
||||||
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
|
comment := cmt.Comments[i]
|
||||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
eqIdx := strings.Index(comment, "=")
|
||||||
|
if eqIdx > 0 {
|
||||||
|
existingKey := strings.ToUpper(comment[:eqIdx])
|
||||||
|
if existingKey == keyUpper {
|
||||||
|
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add new
|
// Add new
|
||||||
@@ -335,3 +342,449 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
|||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||||
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.VorbisComment {
|
||||||
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try LYRICS tag first
|
||||||
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to UNSYNCEDLYRICS
|
||||||
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
|
return lyrics[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no lyrics found in file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioQuality represents audio quality info from a FLAC file
|
||||||
|
type AudioQuality struct {
|
||||||
|
BitDepth int `json:"bit_depth"`
|
||||||
|
SampleRate int `json:"sample_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||||
|
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||||
|
// For M4A files, it delegates to GetM4AQuality
|
||||||
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Read first 4 bytes to detect file type
|
||||||
|
marker := make([]byte, 4)
|
||||||
|
if _, err := file.Read(marker); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a FLAC file
|
||||||
|
if string(marker) == "fLaC" {
|
||||||
|
// Continue reading FLAC metadata
|
||||||
|
// Read metadata block header (4 bytes)
|
||||||
|
header := make([]byte, 4)
|
||||||
|
if _, err := file.Read(header); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockType := header[0] & 0x7F
|
||||||
|
if blockType != 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read STREAMINFO block (34 bytes minimum)
|
||||||
|
streamInfo := make([]byte, 34)
|
||||||
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse sample rate (20 bits starting at byte 10)
|
||||||
|
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||||
|
|
||||||
|
// Parse bits per sample (5 bits)
|
||||||
|
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(header8[4:8]) == "ftyp" {
|
||||||
|
// It's an M4A/MP4 file, use M4A quality reader
|
||||||
|
file.Close() // Close before calling GetM4AQuality which opens the file again
|
||||||
|
return GetM4AQuality(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// M4A (MP4/AAC) Metadata Embedding
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||||
|
// This is a simplified implementation that writes metadata to the file
|
||||||
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
|
// Read the entire file
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find moov atom position
|
||||||
|
moovPos := findAtom(data, "moov", 0)
|
||||||
|
if moovPos < 0 {
|
||||||
|
return fmt.Errorf("moov atom not found in M4A file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find udta atom inside moov, or create one
|
||||||
|
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
|
||||||
|
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||||
|
|
||||||
|
// Build new metadata atoms
|
||||||
|
metaAtom := buildMetaAtom(metadata, coverData)
|
||||||
|
|
||||||
|
var newData []byte
|
||||||
|
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||||
|
// udta exists, find meta inside it or replace
|
||||||
|
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
|
||||||
|
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||||
|
|
||||||
|
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||||
|
// Replace existing meta atom
|
||||||
|
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
|
||||||
|
newData = append(newData, data[:metaPos]...)
|
||||||
|
newData = append(newData, metaAtom...)
|
||||||
|
newData = append(newData, data[metaPos+metaSize:]...)
|
||||||
|
} else {
|
||||||
|
// Add meta atom to udta
|
||||||
|
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||||
|
newUdtaSize := 8 + len(newUdtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(newUdtaSize >> 24)
|
||||||
|
newUdta[1] = byte(newUdtaSize >> 16)
|
||||||
|
newUdta[2] = byte(newUdtaSize >> 8)
|
||||||
|
newUdta[3] = byte(newUdtaSize)
|
||||||
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
|
newUdta = append(newUdta, newUdtaContent...)
|
||||||
|
|
||||||
|
newData = append(newData, data[:udtaPos]...)
|
||||||
|
newData = append(newData, newUdta...)
|
||||||
|
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new udta with meta
|
||||||
|
udtaContent := metaAtom
|
||||||
|
udtaSize := 8 + len(udtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(udtaSize >> 24)
|
||||||
|
newUdta[1] = byte(udtaSize >> 16)
|
||||||
|
newUdta[2] = byte(udtaSize >> 8)
|
||||||
|
newUdta[3] = byte(udtaSize)
|
||||||
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
|
newUdta = append(newUdta, udtaContent...)
|
||||||
|
|
||||||
|
// Insert udta at end of moov
|
||||||
|
insertPos := moovPos + moovSize
|
||||||
|
newData = append(newData, data[:insertPos]...)
|
||||||
|
newData = append(newData, newUdta...)
|
||||||
|
newData = append(newData, data[insertPos:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update moov size
|
||||||
|
newMoovSize := moovSize + len(newData) - len(data)
|
||||||
|
newData[moovPos] = byte(newMoovSize >> 24)
|
||||||
|
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||||
|
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||||
|
newData[moovPos+3] = byte(newMoovSize)
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAtom finds an atom by name starting from offset
|
||||||
|
func findAtom(data []byte, name string, offset int) int {
|
||||||
|
for i := offset; i < len(data)-8; {
|
||||||
|
size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
|
||||||
|
if size < 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
atomName := string(data[i+4 : i+8])
|
||||||
|
if atomName == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||||
|
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||||
|
// Build ilst content
|
||||||
|
var ilst []byte
|
||||||
|
|
||||||
|
// ©nam - Title
|
||||||
|
if metadata.Title != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©ART - Artist
|
||||||
|
if metadata.Artist != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©alb - Album
|
||||||
|
if metadata.Album != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aART - Album Artist
|
||||||
|
if metadata.AlbumArtist != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©day - Year/Date
|
||||||
|
if metadata.Date != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trkn - Track Number
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk - Disc Number
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©lyr - Lyrics
|
||||||
|
if metadata.Lyrics != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// covr - Cover Art
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ilst atom
|
||||||
|
ilstSize := 8 + len(ilst)
|
||||||
|
ilstAtom := make([]byte, 4)
|
||||||
|
ilstAtom[0] = byte(ilstSize >> 24)
|
||||||
|
ilstAtom[1] = byte(ilstSize >> 16)
|
||||||
|
ilstAtom[2] = byte(ilstSize >> 8)
|
||||||
|
ilstAtom[3] = byte(ilstSize)
|
||||||
|
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||||
|
ilstAtom = append(ilstAtom, ilst...)
|
||||||
|
|
||||||
|
// Build hdlr atom (required for meta)
|
||||||
|
hdlr := []byte{
|
||||||
|
0, 0, 0, 33, // size = 33
|
||||||
|
'h', 'd', 'l', 'r',
|
||||||
|
0, 0, 0, 0, // version + flags
|
||||||
|
0, 0, 0, 0, // predefined
|
||||||
|
'm', 'd', 'i', 'r', // handler type
|
||||||
|
'a', 'p', 'p', 'l', // manufacturer
|
||||||
|
0, 0, 0, 0, // component flags
|
||||||
|
0, 0, 0, 0, // component flags mask
|
||||||
|
0, // null terminator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build meta atom
|
||||||
|
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||||
|
metaContent = append(metaContent, ilstAtom...)
|
||||||
|
|
||||||
|
metaSize := 8 + len(metaContent)
|
||||||
|
metaAtom := make([]byte, 4)
|
||||||
|
metaAtom[0] = byte(metaSize >> 24)
|
||||||
|
metaAtom[1] = byte(metaSize >> 16)
|
||||||
|
metaAtom[2] = byte(metaSize >> 8)
|
||||||
|
metaAtom[3] = byte(metaSize)
|
||||||
|
metaAtom = append(metaAtom, []byte("meta")...)
|
||||||
|
metaAtom = append(metaAtom, metaContent...)
|
||||||
|
|
||||||
|
return metaAtom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||||
|
func buildTextAtom(name, value string) []byte {
|
||||||
|
valueBytes := []byte(value)
|
||||||
|
|
||||||
|
// data atom
|
||||||
|
dataSize := 16 + len(valueBytes)
|
||||||
|
dataAtom := make([]byte, 4)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
|
dataAtom = append(dataAtom, valueBytes...)
|
||||||
|
|
||||||
|
// container atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte(name)...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTrackNumberAtom builds trkn atom
|
||||||
|
func buildTrackNumberAtom(track, total int) []byte {
|
||||||
|
// data atom with track number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 24, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(track >> 8), byte(track), // track number
|
||||||
|
byte(total >> 8), byte(total), // total tracks
|
||||||
|
0, 0, // padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// trkn atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("trkn")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiscNumberAtom builds disk atom
|
||||||
|
func buildDiscNumberAtom(disc, total int) []byte {
|
||||||
|
// data atom with disc number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 22, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(disc >> 8), byte(disc), // disc number
|
||||||
|
byte(total >> 8), byte(total), // total discs
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("disk")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCoverAtom builds covr atom with image data
|
||||||
|
func buildCoverAtom(coverData []byte) []byte {
|
||||||
|
// Detect image type (JPEG = 13, PNG = 14)
|
||||||
|
imageType := byte(13) // default JPEG
|
||||||
|
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||||
|
imageType = 14 // PNG
|
||||||
|
}
|
||||||
|
|
||||||
|
// data atom
|
||||||
|
dataSize := 16 + len(coverData)
|
||||||
|
dataAtom := make([]byte, 4)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
|
dataAtom = append(dataAtom, coverData...)
|
||||||
|
|
||||||
|
// covr atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("covr")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetM4AQuality reads audio quality from M4A file
|
||||||
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
||||||
|
moovPos := findAtom(data, "moov", 0)
|
||||||
|
if moovPos < 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for mp4a or alac atom which contains audio info
|
||||||
|
// This is a simplified search - real implementation would traverse the atom tree
|
||||||
|
for i := moovPos; i < len(data)-20; i++ {
|
||||||
|
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||||
|
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
||||||
|
if i+24 < len(data) {
|
||||||
|
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
||||||
|
// For AAC, bit depth is typically 16
|
||||||
|
bitDepth := 16
|
||||||
|
if string(data[i:i+4]) == "alac" {
|
||||||
|
// ALAC can have higher bit depth, check esds or alac specific data
|
||||||
|
bitDepth = 24 // Assume 24-bit for ALAC
|
||||||
|
}
|
||||||
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ISRC to Track ID Cache
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// TrackIDCacheEntry holds cached track ID with metadata
|
||||||
|
type TrackIDCacheEntry struct {
|
||||||
|
TidalTrackID int64
|
||||||
|
QobuzTrackID int64
|
||||||
|
AmazonTrackID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackIDCache caches ISRC to track ID mappings
|
||||||
|
type TrackIDCache struct {
|
||||||
|
cache map[string]*TrackIDCacheEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalTrackIDCache *TrackIDCache
|
||||||
|
trackIDCacheOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTrackIDCache returns the global track ID cache
|
||||||
|
func GetTrackIDCache() *TrackIDCache {
|
||||||
|
trackIDCacheOnce.Do(func() {
|
||||||
|
globalTrackIDCache = &TrackIDCache{
|
||||||
|
cache: make(map[string]*TrackIDCacheEntry),
|
||||||
|
ttl: 30 * time.Minute, // Cache for 30 minutes
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalTrackIDCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a cached entry by ISRC
|
||||||
|
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists || time.Now().After(entry.ExpiresAt) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTidal caches Tidal track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.TidalTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQobuz caches Qobuz track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.QobuzTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAmazon caches Amazon track ID for an ISRC
|
||||||
|
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := c.cache[isrc]
|
||||||
|
if !exists {
|
||||||
|
entry = &TrackIDCacheEntry{}
|
||||||
|
c.cache[isrc] = entry
|
||||||
|
}
|
||||||
|
entry.AmazonTrackID = trackID
|
||||||
|
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all cached entries
|
||||||
|
func (c *TrackIDCache) Clear() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the number of cached entries
|
||||||
|
func (c *TrackIDCache) Size() int {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return len(c.cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Parallel Download Helper
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// ParallelDownloadResult holds results from parallel operations
|
||||||
|
type ParallelDownloadResult struct {
|
||||||
|
CoverData []byte
|
||||||
|
LyricsData *LyricsResponse
|
||||||
|
LyricsLRC string
|
||||||
|
CoverErr error
|
||||||
|
LyricsErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
||||||
|
// This runs while the main audio download is happening
|
||||||
|
func FetchCoverAndLyricsParallel(
|
||||||
|
coverURL string,
|
||||||
|
maxQualityCover bool,
|
||||||
|
spotifyID string,
|
||||||
|
trackName string,
|
||||||
|
artistName string,
|
||||||
|
embedLyrics bool,
|
||||||
|
) *ParallelDownloadResult {
|
||||||
|
result := &ParallelDownloadResult{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Download cover in parallel
|
||||||
|
if coverURL != "" {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
fmt.Println("[Parallel] Starting cover download...")
|
||||||
|
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||||
|
if err != nil {
|
||||||
|
result.CoverErr = err
|
||||||
|
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
result.CoverData = data
|
||||||
|
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch lyrics in parallel
|
||||||
|
if embedLyrics {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
fmt.Println("[Parallel] Starting lyrics fetch...")
|
||||||
|
client := NewLyricsClient()
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
||||||
|
if err != nil {
|
||||||
|
result.LyricsErr = err
|
||||||
|
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||||
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
|
result.LyricsData = lyrics
|
||||||
|
// Use LRC with metadata headers (like PC version)
|
||||||
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
|
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||||
|
} else {
|
||||||
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||||
|
fmt.Println("[Parallel] No lyrics found")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Pre-warm Cache for Album/Playlist
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// PreWarmCacheRequest represents a track to pre-warm cache for
|
||||||
|
type PreWarmCacheRequest struct {
|
||||||
|
ISRC string
|
||||||
|
TrackName string
|
||||||
|
ArtistName string
|
||||||
|
SpotifyID string // Needed for Amazon (SongLink lookup)
|
||||||
|
Service string // "tidal", "qobuz", "amazon"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
|
||||||
|
// This runs in background while user is viewing the track list
|
||||||
|
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||||
|
if len(requests) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
||||||
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
|
// Limit concurrent pre-warm requests
|
||||||
|
semaphore := make(chan struct{}, 3) // Max 3 concurrent
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, req := range requests {
|
||||||
|
// Skip if already cached
|
||||||
|
if cached := cache.Get(req.ISRC); cached != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func(r PreWarmCacheRequest) {
|
||||||
|
defer wg.Done()
|
||||||
|
semaphore <- struct{}{} // Acquire
|
||||||
|
defer func() { <-semaphore }() // Release
|
||||||
|
|
||||||
|
switch r.Service {
|
||||||
|
case "tidal":
|
||||||
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||||
|
case "qobuz":
|
||||||
|
preWarmQobuzCache(r.ISRC)
|
||||||
|
case "amazon":
|
||||||
|
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||||
|
}
|
||||||
|
}(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmTidalCache(isrc, trackName, artistName string) {
|
||||||
|
downloader := NewTidalDownloader()
|
||||||
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||||
|
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmQobuzCache(isrc string) {
|
||||||
|
downloader := NewQobuzDownloader()
|
||||||
|
track, err := downloader.SearchTrackByISRC(isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||||
|
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||||
|
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
|
if err == nil && availability != nil && availability.Amazon {
|
||||||
|
// Store Amazon URL in cache (using ISRC as key)
|
||||||
|
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||||
|
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Exported Functions for Flutter
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
|
||||||
|
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
||||||
|
func PreWarmCache(tracksJSON string) error {
|
||||||
|
var requests []PreWarmCacheRequest
|
||||||
|
// Parse JSON (simplified - in production use proper JSON parsing)
|
||||||
|
// For now, this is called from exports.go with proper parsing
|
||||||
|
|
||||||
|
go PreWarmTrackCache(requests) // Run in background
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTrackCache clears the track ID cache
|
||||||
|
func ClearTrackCache() {
|
||||||
|
GetTrackIDCache().Clear()
|
||||||
|
fmt.Println("[Cache] Track ID cache cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCacheSize returns the current cache size
|
||||||
|
func GetCacheSize() int {
|
||||||
|
return GetTrackIDCache().Size()
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress
|
// DownloadProgress represents current download progress
|
||||||
|
// Now unified - returns data from multi-progress system
|
||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
CurrentFile string `json:"current_file"`
|
CurrentFile string `json:"current_file"`
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
@@ -12,54 +15,184 @@ type DownloadProgress struct {
|
|||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ItemProgress represents progress for a single download item
|
||||||
|
type ItemProgress struct {
|
||||||
|
ItemID string `json:"item_id"`
|
||||||
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
|
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||||
|
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||||
|
IsDownloading bool `json:"is_downloading"`
|
||||||
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiProgress holds progress for multiple concurrent downloads
|
||||||
|
type MultiProgress struct {
|
||||||
|
Items map[string]*ItemProgress `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
currentProgress DownloadProgress
|
downloadDir string
|
||||||
progressMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
downloadDir string
|
|
||||||
downloadDirMu sync.RWMutex
|
// Multi-download progress tracking (unified system)
|
||||||
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
|
multiMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// getProgress returns current download progress
|
// getProgress returns current download progress from multi-progress system
|
||||||
|
// Returns first active item's progress for backward compatibility
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
progressMu.RLock()
|
multiMu.RLock()
|
||||||
defer progressMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
return currentProgress
|
|
||||||
|
// Find first active item
|
||||||
|
for _, item := range multiProgress.Items {
|
||||||
|
return DownloadProgress{
|
||||||
|
CurrentFile: item.ItemID,
|
||||||
|
Progress: item.Progress * 100, // Convert to percentage
|
||||||
|
BytesTotal: item.BytesTotal,
|
||||||
|
BytesReceived: item.BytesReceived,
|
||||||
|
IsDownloading: item.IsDownloading,
|
||||||
|
Status: item.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadProgress{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadProgress sets the current download progress (MB downloaded)
|
// GetMultiProgress returns progress for all active downloads as JSON
|
||||||
func SetDownloadProgress(mbDownloaded float64) {
|
func GetMultiProgress() string {
|
||||||
progressMu.Lock()
|
multiMu.RLock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.RUnlock()
|
||||||
currentProgress.Progress = mbDownloaded
|
|
||||||
currentProgress.IsDownloading = true
|
jsonBytes, err := json.Marshal(multiProgress)
|
||||||
|
if err != nil {
|
||||||
|
return "{\"items\":{}}"
|
||||||
|
}
|
||||||
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloadSpeed sets the current download speed
|
// GetItemProgress returns progress for a specific item as JSON
|
||||||
func SetDownloadSpeed(speedMBps float64) {
|
func GetItemProgress(itemID string) string {
|
||||||
progressMu.Lock()
|
multiMu.RLock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.RUnlock()
|
||||||
currentProgress.Speed = speedMBps
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
jsonBytes, _ := json.Marshal(item)
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
return "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCurrentFile sets the current file being downloaded and resets progress
|
// StartItemProgress initializes progress tracking for an item
|
||||||
func SetCurrentFile(filename string) {
|
func StartItemProgress(itemID string) {
|
||||||
progressMu.Lock()
|
multiMu.Lock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
// Reset progress for new file
|
|
||||||
currentProgress.BytesReceived = 0
|
multiProgress.Items[itemID] = &ItemProgress{
|
||||||
currentProgress.BytesTotal = 0
|
ItemID: itemID,
|
||||||
currentProgress.Progress = 0
|
BytesTotal: 0,
|
||||||
currentProgress.CurrentFile = filename
|
BytesReceived: 0,
|
||||||
currentProgress.IsDownloading = true
|
Progress: 0,
|
||||||
|
IsDownloading: true,
|
||||||
|
Status: "downloading",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetProgress resets the download progress
|
// SetItemBytesTotal sets total bytes for an item
|
||||||
func ResetProgress() {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
progressMu.Lock()
|
multiMu.Lock()
|
||||||
defer progressMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
currentProgress = DownloadProgress{}
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesTotal = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemBytesReceived sets bytes received for an item
|
||||||
|
func SetItemBytesReceived(itemID string, received int64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesReceived = received
|
||||||
|
if item.BytesTotal > 0 {
|
||||||
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
||||||
|
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesReceived = received
|
||||||
|
item.SpeedMBps = speedMBps
|
||||||
|
if item.BytesTotal > 0 {
|
||||||
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteItemProgress marks an item as complete
|
||||||
|
func CompleteItemProgress(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = 1.0
|
||||||
|
item.IsDownloading = false
|
||||||
|
item.Status = "completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemProgress sets progress for an item directly
|
||||||
|
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = progress
|
||||||
|
if bytesReceived > 0 {
|
||||||
|
item.BytesReceived = bytesReceived
|
||||||
|
}
|
||||||
|
if bytesTotal > 0 {
|
||||||
|
item.BytesTotal = bytesTotal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||||
|
func SetItemFinalizing(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.Progress = 1.0
|
||||||
|
item.Status = "finalizing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItemProgress removes progress tracking for an item
|
||||||
|
func RemoveItemProgress(itemID string) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
delete(multiProgress.Items, itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAllItemProgress clears all item progress
|
||||||
|
func ClearAllItemProgress() {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDownloadDir sets the default download directory
|
// setDownloadDir sets the default download directory
|
||||||
@@ -77,61 +210,57 @@ func getDownloadDir() string {
|
|||||||
return downloadDir
|
return downloadDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDownloading sets the download status
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
func SetDownloading(status bool) {
|
type ItemProgressWriter struct {
|
||||||
progressMu.Lock()
|
writer interface{ Write([]byte) (int, error) }
|
||||||
defer progressMu.Unlock()
|
itemID string
|
||||||
currentProgress.IsDownloading = status
|
current int64
|
||||||
|
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||||
|
startTime time.Time // Track start time for speed calculation
|
||||||
|
lastTime time.Time // Track last update time for speed calculation
|
||||||
|
lastBytes int64 // Track bytes at last speed calculation
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBytesTotal sets total bytes to download
|
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||||
func SetBytesTotal(total int64) {
|
|
||||||
progressMu.Lock()
|
|
||||||
defer progressMu.Unlock()
|
|
||||||
currentProgress.BytesTotal = total
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBytesReceived sets bytes received so far
|
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||||
func SetBytesReceived(received int64) {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
progressMu.Lock()
|
now := time.Now()
|
||||||
defer progressMu.Unlock()
|
return &ItemProgressWriter{
|
||||||
currentProgress.BytesReceived = received
|
writer: w,
|
||||||
if currentProgress.BytesTotal > 0 {
|
itemID: itemID,
|
||||||
currentProgress.Progress = float64(received) / float64(currentProgress.BytesTotal) * 100
|
current: 0,
|
||||||
|
lastReported: 0,
|
||||||
|
startTime: now,
|
||||||
|
lastTime: now,
|
||||||
|
lastBytes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProgressWriter wraps io.Writer to track download progress
|
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||||
type ProgressWriter struct {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
|
||||||
total int64
|
|
||||||
current int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewProgressWriter creates a new progress writer wrapping an io.Writer
|
|
||||||
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
|
|
||||||
// Reset bytes received when starting new download
|
|
||||||
SetBytesReceived(0)
|
|
||||||
return &ProgressWriter{
|
|
||||||
writer: w,
|
|
||||||
current: 0,
|
|
||||||
total: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write implements io.Writer
|
|
||||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
pw.total += int64(n)
|
|
||||||
SetBytesReceived(pw.current)
|
// Update progress when we've received at least 64KB since last update
|
||||||
|
// Also update on first write to show download has started
|
||||||
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
|
// Calculate speed (MB/s) based on bytes received since last update
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||||
|
var speedMBps float64
|
||||||
|
if elapsed > 0 {
|
||||||
|
bytesInInterval := pw.current - pw.lastBytes
|
||||||
|
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
|
||||||
|
pw.lastReported = pw.current
|
||||||
|
pw.lastTime = now
|
||||||
|
pw.lastBytes = pw.current
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTotal returns total bytes written
|
|
||||||
func (pw *ProgressWriter) GetTotal() int64 {
|
|
||||||
return pw.total
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,6 +10,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QobuzDownloader handles Qobuz downloads
|
// QobuzDownloader handles Qobuz downloads
|
||||||
@@ -18,6 +21,12 @@ type QobuzDownloader struct {
|
|||||||
apiURL string
|
apiURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Global Qobuz downloader instance for connection reuse
|
||||||
|
globalQobuzDownloader *QobuzDownloader
|
||||||
|
qobuzDownloaderOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
// QobuzTrack represents a Qobuz track
|
// QobuzTrack represents a Qobuz track
|
||||||
type QobuzTrack struct {
|
type QobuzTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -39,12 +48,82 @@ type QobuzTrack struct {
|
|||||||
} `json:"performer"`
|
} `json:"performer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewQobuzDownloader creates a new Qobuz downloader
|
// qobuzArtistsMatch checks if the artist names are similar enough
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||||
return &QobuzDownloader{
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
appID: "798273057",
|
|
||||||
|
// Exact match
|
||||||
|
if normExpected == normFound {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if one contains the other
|
||||||
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first artist (before comma or feat)
|
||||||
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
|
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||||
|
|
||||||
|
foundFirst := strings.Split(normFound, ",")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||||
|
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||||
|
foundFirst = strings.TrimSpace(foundFirst)
|
||||||
|
|
||||||
|
if expectedFirst == foundFirst {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first artist is contained in the other
|
||||||
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||||
|
// assume they're the same artist with different transliteration
|
||||||
|
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
||||||
|
foundASCII := qobuzIsASCIIString(foundArtist)
|
||||||
|
if expectedASCII != foundASCII {
|
||||||
|
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||||
|
func qobuzIsASCIIString(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsQueryQobuz checks if a query already exists in the list
|
||||||
|
func containsQueryQobuz(queries []string, query string) bool {
|
||||||
|
for _, q := range queries {
|
||||||
|
if q == query {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
||||||
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
|
qobuzDownloaderOnce.Do(func() {
|
||||||
|
globalQobuzDownloader = &QobuzDownloader{
|
||||||
|
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
|
||||||
|
appID: "798273057",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalQobuzDownloader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||||
@@ -112,11 +191,100 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||||
|
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||||
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []QobuzTrack `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ISRC matches
|
||||||
|
var isrcMatches []*QobuzTrack
|
||||||
|
for i := range result.Tracks.Items {
|
||||||
|
if result.Tracks.Items[i].ISRC == isrc {
|
||||||
|
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(isrcMatches) > 0 {
|
||||||
|
// Verify duration if provided
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationVerifiedMatches []*QobuzTrack
|
||||||
|
for _, track := range isrcMatches {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||||
|
return durationVerifiedMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC matches but duration doesn't
|
||||||
|
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||||
|
expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration to verify, return first match
|
||||||
|
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
|
return isrcMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Tracks.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
|
||||||
|
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
|
||||||
|
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
||||||
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||||
|
// Now includes romaji conversion for Japanese text (same as Tidal)
|
||||||
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies
|
// Try multiple search strategies (same as Tidal/PC version)
|
||||||
queries := []string{}
|
queries := []string{}
|
||||||
|
|
||||||
// Strategy 1: Artist + Track name
|
// Strategy 1: Artist + Track name
|
||||||
@@ -129,8 +297,54 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Romaji versions if Japanese detected
|
||||||
|
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||||
|
// Convert to romaji (hiragana/katakana only, kanji stays)
|
||||||
|
romajiTrack := JapaneseToRomaji(trackName)
|
||||||
|
romajiArtist := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
|
// Clean and remove ALL non-ASCII characters (including kanji)
|
||||||
|
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||||
|
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||||
|
|
||||||
|
// Artist + Track romaji (cleaned to ASCII only)
|
||||||
|
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||||
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
|
if !containsQueryQobuz(queries, romajiQuery) {
|
||||||
|
queries = append(queries, romajiQuery)
|
||||||
|
fmt.Printf("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track romaji only (cleaned)
|
||||||
|
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||||
|
if !containsQueryQobuz(queries, cleanRomajiTrack) {
|
||||||
|
queries = append(queries, cleanRomajiTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: Artist only as last resort
|
||||||
|
if artistName != "" {
|
||||||
|
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||||
|
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
|
||||||
|
queries = append(queries, artistOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allTracks []QobuzTrack
|
||||||
|
searchedQueries := make(map[string]bool)
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
cleanQuery := strings.TrimSpace(query)
|
||||||
|
if cleanQuery == "" || searchedQueries[cleanQuery] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
searchedQueries[cleanQuery] = true
|
||||||
|
|
||||||
|
fmt.Printf("[Qobuz] Searching for: %s\n", cleanQuery)
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,6 +353,7 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,19 +374,51 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
// Return first result with best quality
|
fmt.Printf("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
|
||||||
for i := range result.Tracks.Items {
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
track := &result.Tracks.Items[i]
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allTracks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If duration verification is requested
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationMatches []*QobuzTrack
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationMatches = append(durationMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationMatches) > 0 {
|
||||||
|
// Return best quality among duration matches
|
||||||
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Return first result if no hi-res found
|
return durationMatches[0], nil
|
||||||
return &result.Tracks.Items[0], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No duration match found
|
||||||
|
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
// No duration verification, return best quality
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &allTracks[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||||
@@ -261,11 +508,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
if itemID != "" {
|
||||||
SetDownloading(true)
|
StartItemProgress(itemID)
|
||||||
defer SetDownloading(false)
|
defer CompleteItemProgress(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -282,51 +530,132 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string) error {
|
|||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
// Use ProgressWriter for tracking
|
// Use buffered writer for better performance (256KB buffer)
|
||||||
progressWriter := NewProgressWriter(out)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
|
||||||
return err
|
// Use item progress writer with buffered output
|
||||||
|
var written int64
|
||||||
|
if itemID != "" {
|
||||||
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
|
} else {
|
||||||
|
// Fallback: direct copy without progress tracking
|
||||||
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush buffer before checking for errors
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
// Check for any errors
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file size if Content-Length was provided
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QobuzDownloadResult contains download result with quality info
|
||||||
|
type QobuzDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromQobuz downloads a track using the request parameters
|
// downloadFromQobuz downloads a track using the request parameters
|
||||||
func downloadFromQobuz(req DownloadRequest) (string, error) {
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
// Check for existing file first
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return "EXISTS:" + existingFile, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC
|
// OPTIMIZATION: Check cache first for track ID
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
|
fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||||
|
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||||
|
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
|
if track == nil && req.ISRC != "" {
|
||||||
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
|
// Verify artist
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Search by metadata with duration verification
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
|
// Verify artist
|
||||||
|
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||||
|
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
req.ArtistName, track.Performer.Name)
|
||||||
|
track = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
errMsg := "could not find track on Qobuz"
|
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg = err.Error()
|
errMsg = err.Error()
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("qobuz search failed: %s", errMsg)
|
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log match found and cache the track ID
|
||||||
|
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||||
|
if req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
@@ -343,69 +672,113 @@ func downloadFromQobuz(req DownloadRequest) (string, error) {
|
|||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return "EXISTS:" + outputPath, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map quality from Tidal format to Qobuz format
|
||||||
|
// Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res)
|
||||||
|
// Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz)
|
||||||
|
qobuzQuality := "27" // Default to highest quality
|
||||||
|
switch req.Quality {
|
||||||
|
case "LOSSLESS":
|
||||||
|
qobuzQuality = "6" // 16-bit FLAC
|
||||||
|
case "HI_RES":
|
||||||
|
qobuzQuality = "7" // 24-bit 96kHz
|
||||||
|
case "HI_RES_LOSSLESS":
|
||||||
|
qobuzQuality = "27" // 24-bit 192kHz
|
||||||
|
}
|
||||||
|
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
|
// Get actual quality from track metadata
|
||||||
|
actualBitDepth := track.MaximumBitDepth
|
||||||
|
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||||
|
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
// Get download URL using parallel API requests
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, "27") // 27 = FLAC 24-bit
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download file
|
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
var parallelResult *ParallelDownloadResult
|
||||||
return "", fmt.Errorf("download failed: %w", err)
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
req.CoverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
req.EmbedLyrics,
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download audio file with item ID for progress tracking
|
||||||
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata
|
// Wait for parallel operations to complete
|
||||||
|
<-parallelDone
|
||||||
|
|
||||||
|
// Set progress to 100% and status to finalizing (before embedding)
|
||||||
|
// This makes the UI show "Finalizing..." while embedding happens
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed metadata using parallel-fetched cover data
|
||||||
|
// Use metadata from the actual Qobuz track found (more accurate than request)
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
Title: req.TrackName,
|
Title: track.Title,
|
||||||
Artist: req.ArtistName,
|
Artist: track.Performer.Name,
|
||||||
Album: req.AlbumName,
|
Album: track.Album.Title,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
|
||||||
Date: req.ReleaseDate,
|
Date: track.Album.ReleaseDate,
|
||||||
TrackNumber: req.TrackNumber,
|
TrackNumber: track.TrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||||
ISRC: req.ISRC,
|
ISRC: track.ISRC,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download cover to memory (avoids file permission issues on Android)
|
// Use cover data from parallel fetch
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if req.CoverURL != "" {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
fmt.Println("[Qobuz] Downloading cover to memory...")
|
coverData = parallelResult.CoverData
|
||||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
fmt.Printf("[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...")
|
fmt.Printf("[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)
|
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
if lyricsErr != nil {
|
|
||||||
fmt.Printf("[Qobuz] Warning: lyrics fetch error: %v\n", lyricsErr)
|
|
||||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
|
||||||
fmt.Println("[Qobuz] No lyrics found for this track")
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||||
lrcContent := convertToLRC(lyrics)
|
|
||||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
|
||||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if req.EmbedLyrics {
|
||||||
|
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputPath, nil
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
|
return QobuzDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: actualBitDepth,
|
||||||
|
SampleRate: actualSampleRate,
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: track.Performer.Name,
|
||||||
|
Album: track.Album.Title,
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,109 +5,61 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Japanese character ranges
|
// Hiragana to Romaji mapping
|
||||||
const (
|
|
||||||
hiraganaStart = 0x3040
|
|
||||||
hiraganaEnd = 0x309F
|
|
||||||
katakanaStart = 0x30A0
|
|
||||||
katakanaEnd = 0x30FF
|
|
||||||
kanjiStart = 0x4E00
|
|
||||||
kanjiEnd = 0x9FFF
|
|
||||||
)
|
|
||||||
|
|
||||||
// hiraganaToRomaji maps hiragana characters to romaji
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
var hiraganaToRomaji = map[rune]string{
|
||||||
// Basic vowels
|
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
// K-row
|
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
// S-row
|
|
||||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||||
// T-row
|
|
||||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||||
// N-row
|
|
||||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||||
// H-row
|
|
||||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||||
// M-row
|
|
||||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||||
// Y-row
|
|
||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
// R-row
|
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
// W-row
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
'わ': "wa", 'を': "wo",
|
// Dakuten (voiced)
|
||||||
// N
|
|
||||||
'ん': "n",
|
|
||||||
// Voiced (dakuten) - G-row
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
// Z-row
|
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
// D-row
|
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
// B-row
|
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
// P-row (handakuten)
|
// Handakuten (semi-voiced)
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
// Small characters
|
// Small characters
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
|
'っ': "", // Double consonant marker
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
'っ': "", // Small tsu - handled specially
|
|
||||||
// Long vowel mark
|
|
||||||
'ー': "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// katakanaToRomaji maps katakana characters to romaji
|
// Katakana to Romaji mapping
|
||||||
var katakanaToRomaji = map[rune]string{
|
var katakanaToRomaji = map[rune]string{
|
||||||
// Basic vowels
|
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
// K-row
|
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
// S-row
|
|
||||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||||
// T-row
|
|
||||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||||
// N-row
|
|
||||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||||
// H-row
|
|
||||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||||
// M-row
|
|
||||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||||
// Y-row
|
|
||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
// R-row
|
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
// W-row
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
'ワ': "wa", 'ヲ': "wo",
|
// Dakuten (voiced)
|
||||||
// N
|
|
||||||
'ン': "n",
|
|
||||||
// Voiced (dakuten) - G-row
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
// Z-row
|
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
// D-row
|
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
// B-row
|
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
// P-row (handakuten)
|
// Handakuten (semi-voiced)
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
// Small characters
|
// Small characters
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
|
'ッ': "", // Double consonant marker
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
'ッ': "", // Small tsu - handled specially
|
|
||||||
// Extended katakana
|
// Extended katakana
|
||||||
|
'ー': "", // Long vowel mark
|
||||||
'ヴ': "vu",
|
'ヴ': "vu",
|
||||||
// Long vowel mark
|
|
||||||
'ー': "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended katakana combinations (multi-character)
|
// Combination mappings for きゃ, しゃ, etc.
|
||||||
var katakanaExtended = map[string]string{
|
var combinationHiragana = map[string]string{
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combination mappings for small ya/yu/yo
|
|
||||||
var hiraganaCombo = map[string]string{
|
|
||||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||||
@@ -121,7 +73,7 @@ var hiraganaCombo = map[string]string{
|
|||||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||||
}
|
}
|
||||||
|
|
||||||
var katakanaCombo = map[string]string{
|
var combinationKatakana = map[string]string{
|
||||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||||
@@ -133,15 +85,13 @@ var katakanaCombo = map[string]string{
|
|||||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
// Extended katakana combinations
|
// Extended combinations
|
||||||
"ティ": "ti", "ディ": "di",
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
"トゥ": "tu", "ドゥ": "du",
|
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
"ヴァ": "va", "ヴィ": "vi", "ヴェ": "ve", "ヴォ": "vo",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainsJapanese checks if a string contains Japanese characters (Hiragana, Katakana, or Kanji)
|
// ContainsJapanese checks if a string contains Japanese characters
|
||||||
func ContainsJapanese(s string) bool {
|
func ContainsJapanese(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||||
@@ -151,107 +101,72 @@ func ContainsJapanese(s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainsKana checks if a string contains Hiragana or Katakana (convertible to romaji)
|
|
||||||
func ContainsKana(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if isHiragana(r) || isKatakana(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHiragana(r rune) bool {
|
func isHiragana(r rune) bool {
|
||||||
return r >= hiraganaStart && r <= hiraganaEnd
|
return r >= 0x3040 && r <= 0x309F
|
||||||
}
|
}
|
||||||
|
|
||||||
func isKatakana(r rune) bool {
|
func isKatakana(r rune) bool {
|
||||||
return r >= katakanaStart && r <= katakanaEnd
|
return r >= 0x30A0 && r <= 0x30FF
|
||||||
}
|
}
|
||||||
|
|
||||||
func isKanji(r rune) bool {
|
func isKanji(r rune) bool {
|
||||||
return r >= kanjiStart && r <= kanjiEnd
|
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
|
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||||
// Kanji characters are preserved as-is since they require dictionary lookup
|
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||||
func ToRomaji(s string) string {
|
func JapaneseToRomaji(text string) string {
|
||||||
if !ContainsKana(s) {
|
if !ContainsJapanese(text) {
|
||||||
return s
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
runes := []rune(s)
|
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
result.Grow(len(s) * 2) // Romaji is typically longer
|
runes := []rune(text)
|
||||||
|
|
||||||
i := 0
|
i := 0
|
||||||
|
|
||||||
for i < len(runes) {
|
for i < len(runes) {
|
||||||
r := runes[i]
|
// Check for っ/ッ (double consonant)
|
||||||
|
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||||
|
nextRomaji := ""
|
||||||
|
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
}
|
||||||
|
if len(nextRomaji) > 0 {
|
||||||
|
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Check for two-character combinations first
|
// Check for two-character combinations
|
||||||
if i+1 < len(runes) {
|
if i < len(runes)-1 {
|
||||||
combo := string(runes[i : i+2])
|
combo := string(runes[i : i+2])
|
||||||
if romaji, ok := hiraganaCombo[combo]; ok {
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
i += 2
|
i += 2
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if romaji, ok := katakanaCombo[combo]; ok {
|
if romaji, ok := combinationKatakana[combo]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
i += 2
|
i += 2
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle small tsu (っ/ッ) - doubles the next consonant
|
|
||||||
if r == 'っ' || r == 'ッ' {
|
|
||||||
if i+1 < len(runes) {
|
|
||||||
nextRune := runes[i+1]
|
|
||||||
var nextRomaji string
|
|
||||||
if romaji, ok := hiraganaToRomaji[nextRune]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
} else if romaji, ok := katakanaToRomaji[nextRune]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
}
|
|
||||||
if len(nextRomaji) > 0 {
|
|
||||||
result.WriteByte(nextRomaji[0]) // Double the consonant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle long vowel mark (ー)
|
|
||||||
if r == 'ー' {
|
|
||||||
// Extend the previous vowel
|
|
||||||
resultStr := result.String()
|
|
||||||
if len(resultStr) > 0 {
|
|
||||||
lastChar := resultStr[len(resultStr)-1]
|
|
||||||
if lastChar == 'a' || lastChar == 'i' || lastChar == 'u' || lastChar == 'e' || lastChar == 'o' {
|
|
||||||
result.WriteByte(lastChar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single character conversion
|
// Single character conversion
|
||||||
|
r := runes[i]
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
i++
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if romaji, ok := katakanaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
result.WriteString(romaji)
|
||||||
i++
|
} else if isKanji(r) {
|
||||||
continue
|
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||||
}
|
result.WriteRune(r)
|
||||||
|
|
||||||
// Keep non-Japanese characters as-is
|
|
||||||
if unicode.IsSpace(r) {
|
|
||||||
result.WriteRune(' ')
|
|
||||||
} else {
|
} else {
|
||||||
|
// Keep other characters (punctuation, spaces, etc.)
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
@@ -260,17 +175,48 @@ func ToRomaji(s string) string {
|
|||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRomajiVariants returns search variants for Japanese text
|
// BuildSearchQuery creates a search query from track name and artist
|
||||||
// Returns the original string plus romaji version if applicable
|
// Converts Japanese to romaji if present
|
||||||
func GetRomajiVariants(s string) []string {
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
variants := []string{s}
|
// Convert Japanese to romaji
|
||||||
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
if ContainsKana(s) {
|
// Clean up the query - remove special characters that might interfere with search
|
||||||
romaji := ToRomaji(s)
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
if romaji != s && strings.TrimSpace(romaji) != "" {
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
variants = append(variants, romaji)
|
|
||||||
|
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanSearchQuery removes special characters that might interfere with search
|
||||||
|
func cleanSearchQuery(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return strings.TrimSpace(result.String())
|
||||||
return variants
|
}
|
||||||
|
|
||||||
|
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||||
|
// This is useful for creating search queries that work better with Tidal's search
|
||||||
|
func CleanToASCII(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == ',' || r == '.' {
|
||||||
|
// Convert punctuation to space
|
||||||
|
result.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean up multiple spaces
|
||||||
|
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,20 +22,37 @@ type TrackAvailability struct {
|
|||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
Amazon bool `json:"amazon"`
|
Amazon bool `json:"amazon"`
|
||||||
Qobuz bool `json:"qobuz"`
|
Qobuz bool `json:"qobuz"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
TidalURL string `json:"tidal_url,omitempty"`
|
TidalURL string `json:"tidal_url,omitempty"`
|
||||||
AmazonURL string `json:"amazon_url,omitempty"`
|
AmazonURL string `json:"amazon_url,omitempty"`
|
||||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSongLinkClient creates a new SongLink client
|
var (
|
||||||
|
// Global SongLink client instance for connection reuse
|
||||||
|
globalSongLinkClient *SongLinkClient
|
||||||
|
songLinkClientOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
return &SongLinkClient{
|
songLinkClientOnce.Do(func() {
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
globalSongLinkClient = &SongLinkClient{
|
||||||
}
|
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalSongLinkClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckTrackAvailability checks track availability on streaming platforms
|
// CheckTrackAvailability checks track availability on streaming platforms
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
|
// Validate Spotify ID format (should be 22 characters alphanumeric)
|
||||||
|
if spotifyTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("spotify track ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
// Use global rate limiter - blocks until request is allowed
|
// Use global rate limiter - blocks until request is allowed
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
@@ -57,8 +76,18 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
body, err := ReadResponseBody(resp)
|
||||||
@@ -92,7 +121,15 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
}
|
}
|
||||||
@@ -151,3 +188,357 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
|
|
||||||
return searchResp.Tracks.Total > 0
|
return searchResp.Tracks.Total > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||||
|
func extractDeezerIDFromURL(deezerURL string) string {
|
||||||
|
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
|
||||||
|
parts := strings.Split(deezerURL, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
// Get the last part which should be the ID
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
// Remove any query parameters
|
||||||
|
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
||||||
|
lastPart = lastPart[:idx]
|
||||||
|
}
|
||||||
|
return lastPart
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Deezer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.DeezerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumAvailability represents album availability on different platforms
|
||||||
|
type AlbumAvailability struct {
|
||||||
|
SpotifyID string `json:"spotify_id"`
|
||||||
|
Deezer bool `json:"deezer"`
|
||||||
|
DeezerURL string `json:"deezer_url,omitempty"`
|
||||||
|
DeezerID string `json:"deezer_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
||||||
|
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build API URL for album
|
||||||
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||||
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||||
|
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &AlbumAvailability{
|
||||||
|
SpotifyID: spotifyAlbumID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
|
||||||
|
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Deezer || availability.DeezerID == "" {
|
||||||
|
return "", fmt.Errorf("album not found on Deezer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.DeezerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Deezer ID Support - Query SongLink using Deezer as source
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
||||||
|
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
if deezerTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build Deezer URL
|
||||||
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
|
// Build API URL using Deezer URL as source
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
EntitiesByUniqueId map[string]struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
} `json:"entitiesByUniqueId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
Deezer: true,
|
||||||
|
DeezerID: deezerTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
// Extract Spotify ID from URL
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer URL
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
||||||
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||||
|
// entityType: "song" or "album"
|
||||||
|
// entityID: the ID on that platform
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entityID string) (*TrackAvailability, error) {
|
||||||
|
if entityID == "" {
|
||||||
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build API URL using platform, type, and id parameters (as per API docs)
|
||||||
|
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
||||||
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||||
|
url.QueryEscape(platform),
|
||||||
|
url.QueryEscape(entityType),
|
||||||
|
url.QueryEscape(entityID))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||||
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
|
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
|
||||||
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
// Get the ID part and remove any query parameters
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Spotify")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.SpotifyID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Tidal || availability.TidalURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Tidal")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.TidalURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Amazon Music")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.AmazonURL, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,11 +21,28 @@ const (
|
|||||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||||
|
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||||
|
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||||
|
|
||||||
|
// Cache TTL settings
|
||||||
|
artistCacheTTL = 10 * time.Minute
|
||||||
|
searchCacheTTL = 5 * time.Minute
|
||||||
|
albumCacheTTL = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||||
|
|
||||||
|
// cacheEntry holds cached data with expiration
|
||||||
|
type cacheEntry struct {
|
||||||
|
data interface{}
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *cacheEntry) isExpired() bool {
|
||||||
|
return time.Now().After(e.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
// SpotifyMetadataClient handles Spotify API interactions
|
// SpotifyMetadataClient handles Spotify API interactions
|
||||||
type SpotifyMetadataClient struct {
|
type SpotifyMetadataClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -32,31 +50,76 @@ type SpotifyMetadataClient struct {
|
|||||||
clientSecret string
|
clientSecret string
|
||||||
cachedToken string
|
cachedToken string
|
||||||
tokenExpiresAt time.Time
|
tokenExpiresAt time.Time
|
||||||
|
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
rngMu sync.Mutex
|
rngMu sync.Mutex
|
||||||
userAgent string
|
userAgent string
|
||||||
|
|
||||||
|
// Caches to reduce API calls
|
||||||
|
artistCache map[string]*cacheEntry // key: artistID
|
||||||
|
searchCache map[string]*cacheEntry // key: query+type
|
||||||
|
albumCache map[string]*cacheEntry // key: albumID
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom credentials storage (set from Flutter)
|
||||||
|
var (
|
||||||
|
customClientID string
|
||||||
|
customClientSecret string
|
||||||
|
credentialsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||||
|
// Pass empty strings to use default credentials
|
||||||
|
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||||
|
credentialsMu.Lock()
|
||||||
|
defer credentialsMu.Unlock()
|
||||||
|
customClientID = clientID
|
||||||
|
customClientSecret = clientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCredentials returns the current credentials (custom or default)
|
||||||
|
func getCredentials() (string, string) {
|
||||||
|
credentialsMu.RLock()
|
||||||
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
|
if customClientID != "" && customClientSecret != "" {
|
||||||
|
return customClientID, customClientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to default credentials
|
||||||
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
|
if clientID == "" {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||||
|
clientID = string(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
if clientSecret == "" {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||||
|
clientSecret = string(decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientID, clientSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
// Decode credentials from base64
|
// Get credentials (custom or default)
|
||||||
clientID := ""
|
clientID, clientSecret := getCredentials()
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
|
||||||
clientID = string(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
clientSecret := ""
|
|
||||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
|
||||||
clientSecret = string(decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
searchCache: make(map[string]*cacheEntry),
|
||||||
|
albumCache: make(map[string]*cacheEntry),
|
||||||
}
|
}
|
||||||
c.userAgent = c.randomUserAgent()
|
c.userAgent = c.randomUserAgent()
|
||||||
return c
|
return c
|
||||||
@@ -131,6 +194,32 @@ type PlaylistResponsePayload struct {
|
|||||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArtistInfoMetadata holds artist information
|
||||||
|
type ArtistInfoMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistAlbumMetadata holds album info for artist discography
|
||||||
|
type ArtistAlbumMetadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
AlbumType string `json:"album_type"` // album, single, compilation
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistResponsePayload is the response for artist requests
|
||||||
|
type ArtistResponsePayload struct {
|
||||||
|
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||||
|
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||||
|
}
|
||||||
|
|
||||||
// TrackResponse is the response for single track requests
|
// TrackResponse is the response for single track requests
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
Track TrackMetadata `json:"track"`
|
Track TrackMetadata `json:"track"`
|
||||||
@@ -142,6 +231,21 @@ type SearchResult struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchArtistResult represents an artist in search results
|
||||||
|
type SearchArtistResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images string `json:"images"`
|
||||||
|
Followers int `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAllResult represents combined search results for tracks and artists
|
||||||
|
type SearchAllResult struct {
|
||||||
|
Tracks []TrackMetadata `json:"tracks"`
|
||||||
|
Artists []SearchArtistResult `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
type spotifyURI struct {
|
type spotifyURI struct {
|
||||||
Type string
|
Type string
|
||||||
ID string
|
ID string
|
||||||
@@ -212,6 +316,8 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
|||||||
return c.fetchAlbum(ctx, parsed.ID, token)
|
return c.fetchAlbum(ctx, parsed.ID, token)
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return c.fetchPlaylist(ctx, parsed.ID, token)
|
return c.fetchPlaylist(ctx, parsed.ID, token)
|
||||||
|
case "artist":
|
||||||
|
return c.fetchArtist(ctx, parsed.ID, token)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
}
|
}
|
||||||
@@ -263,6 +369,98 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchAll searches for tracks and artists on Spotify
|
||||||
|
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
|
// Create cache key
|
||||||
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*SearchAllResult), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
token, err := c.getAccessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []trackFull `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
Artists struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
Followers struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &SearchAllResult{
|
||||||
|
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
|
||||||
|
Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, track := range response.Tracks.Items {
|
||||||
|
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||||
|
SpotifyID: track.ID,
|
||||||
|
Artists: joinArtists(track.Artists),
|
||||||
|
Name: track.Name,
|
||||||
|
AlbumName: track.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(track.Album.Artists),
|
||||||
|
DurationMS: track.DurationMS,
|
||||||
|
Images: firstImageURL(track.Album.Images),
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
TotalTracks: track.Album.TotalTracks,
|
||||||
|
DiscNumber: track.DiscNumber,
|
||||||
|
ExternalURL: track.ExternalURL.Spotify,
|
||||||
|
ISRC: track.ExternalID.ISRC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit artists to artistLimit
|
||||||
|
artistCount := len(response.Artists.Items)
|
||||||
|
if artistCount > artistLimit {
|
||||||
|
artistCount = artistLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < artistCount; i++ {
|
||||||
|
artist := response.Artists.Items[i]
|
||||||
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
|
ID: artist.ID,
|
||||||
|
Name: artist.Name,
|
||||||
|
Images: firstImageURL(artist.Images),
|
||||||
|
Followers: artist.Followers.Total,
|
||||||
|
Popularity: artist.Popularity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(searchCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
||||||
var data trackFull
|
var data trackFull
|
||||||
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
||||||
@@ -289,6 +487,25 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Track item structure for pagination
|
||||||
|
type trackItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
|
Artists []artist `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
@@ -296,15 +513,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Items []struct {
|
Items []trackItem `json:"items"`
|
||||||
ID string `json:"id"`
|
Next string `json:"next"`
|
||||||
Name string `json:"name"`
|
|
||||||
DurationMS int `json:"duration_ms"`
|
|
||||||
TrackNumber int `json:"track_number"`
|
|
||||||
DiscNumber int `json:"disc_number"`
|
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
|
||||||
Artists []artist `json:"artists"`
|
|
||||||
} `json:"items"`
|
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,10 +531,38 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
// Collect all tracks (including paginated)
|
||||||
for _, item := range data.Tracks.Items {
|
allTrackItems := data.Tracks.Items
|
||||||
// Fetch ISRC for each track
|
nextURL := data.Tracks.Next
|
||||||
isrc := c.fetchTrackISRC(ctx, item.ID, token)
|
|
||||||
|
// Fetch remaining tracks using pagination (no limit)
|
||||||
|
for nextURL != "" {
|
||||||
|
var pageData struct {
|
||||||
|
Items []trackItem `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
|
fmt.Printf("[Spotify] Warning: failed to fetch album tracks page: %v\n", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
allTrackItems = append(allTrackItems, pageData.Items...)
|
||||||
|
nextURL = pageData.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||||
|
|
||||||
|
// Collect track IDs for parallel ISRC fetching
|
||||||
|
trackIDs := make([]string, len(allTrackItems))
|
||||||
|
for i, item := range allTrackItems {
|
||||||
|
trackIDs[i] = item.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||||
|
for _, item := range allTrackItems {
|
||||||
|
isrc := isrcMap[item.ID]
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.ID,
|
SpotifyID: item.ID,
|
||||||
@@ -344,13 +582,65 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
result := &AlbumResponsePayload{
|
||||||
AlbumInfo: info,
|
AlbumInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(albumCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
||||||
|
// Similar to Deezer implementation for consistency
|
||||||
|
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||||
|
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||||
|
|
||||||
|
result := make(map[string]string)
|
||||||
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
|
if len(trackIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use semaphore to limit concurrent requests
|
||||||
|
sem := make(chan struct{}, maxParallelISRC)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, trackID := range trackIDs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Acquire semaphore
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||||
|
|
||||||
|
resultMu.Lock()
|
||||||
|
result[id] = isrc
|
||||||
|
resultMu.Unlock()
|
||||||
|
}(trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
|
// First request to get playlist info and first batch of tracks
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
@@ -361,7 +651,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
Items []struct {
|
Items []struct {
|
||||||
Track *trackFull `json:"track"`
|
Track *trackFull `json:"track"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
|
Next string `json:"next"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +666,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
info.Owner.Name = data.Name
|
info.Owner.Name = data.Name
|
||||||
info.Owner.Images = firstImageURL(data.Images)
|
info.Owner.Images = firstImageURL(data.Images)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
// Pre-allocate with expected capacity
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||||
|
|
||||||
|
// Add first batch of tracks
|
||||||
for _, item := range data.Tracks.Items {
|
for _, item := range data.Tracks.Items {
|
||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
@@ -399,12 +693,157 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||||
|
nextURL := data.Tracks.Next
|
||||||
|
|
||||||
|
for nextURL != "" {
|
||||||
|
var pageData struct {
|
||||||
|
Items []struct {
|
||||||
|
Track *trackFull `json:"track"`
|
||||||
|
} `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
|
// Log error but return what we have so far
|
||||||
|
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range pageData.Items {
|
||||||
|
if item.Track == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: item.Track.ID,
|
||||||
|
Artists: joinArtists(item.Track.Artists),
|
||||||
|
Name: item.Track.Name,
|
||||||
|
AlbumName: item.Track.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(item.Track.Album.Artists),
|
||||||
|
DurationMS: item.Track.DurationMS,
|
||||||
|
Images: firstImageURL(item.Track.Album.Images),
|
||||||
|
ReleaseDate: item.Track.Album.ReleaseDate,
|
||||||
|
TrackNumber: item.Track.TrackNumber,
|
||||||
|
TotalTracks: item.Track.Album.TotalTracks,
|
||||||
|
DiscNumber: item.Track.DiscNumber,
|
||||||
|
ExternalURL: item.Track.ExternalURL.Spotify,
|
||||||
|
ISRC: item.Track.ExternalID.ISRC,
|
||||||
|
AlbumID: item.Track.Album.ID,
|
||||||
|
AlbumURL: item.Track.Album.ExternalURL.Spotify,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nextURL = pageData.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
|
||||||
|
|
||||||
return &PlaylistResponsePayload{
|
return &PlaylistResponsePayload{
|
||||||
PlaylistInfo: info,
|
PlaylistInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||||
|
// Check cache first
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*ArtistResponsePayload), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Fetch artist info
|
||||||
|
var artistData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
Followers struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
} `json:"followers"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artistInfo := ArtistInfoMetadata{
|
||||||
|
ID: artistData.ID,
|
||||||
|
Name: artistData.Name,
|
||||||
|
Images: firstImageURL(artistData.Images),
|
||||||
|
Followers: artistData.Followers.Total,
|
||||||
|
Popularity: artistData.Popularity,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch artist albums (all types: album, single, compilation)
|
||||||
|
albums := make([]ArtistAlbumMetadata, 0)
|
||||||
|
offset := 0
|
||||||
|
limit := 50
|
||||||
|
|
||||||
|
for {
|
||||||
|
albumsURL := fmt.Sprintf("%s?include_groups=album,single,compilation&limit=%d&offset=%d",
|
||||||
|
fmt.Sprintf(artistAlbumsURL, artistID), limit, offset)
|
||||||
|
|
||||||
|
var albumsData struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
TotalTracks int `json:"total_tracks"`
|
||||||
|
Images []image `json:"images"`
|
||||||
|
AlbumType string `json:"album_type"`
|
||||||
|
Artists []artist `json:"artists"`
|
||||||
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
|
} `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, albumsURL, token, &albumsData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, album := range albumsData.Items {
|
||||||
|
albums = append(albums, ArtistAlbumMetadata{
|
||||||
|
ID: album.ID,
|
||||||
|
Name: album.Name,
|
||||||
|
ReleaseDate: album.ReleaseDate,
|
||||||
|
TotalTracks: album.TotalTracks,
|
||||||
|
Images: firstImageURL(album.Images),
|
||||||
|
AlbumType: album.AlbumType,
|
||||||
|
Artists: joinArtists(album.Artists),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are more albums
|
||||||
|
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += limit
|
||||||
|
|
||||||
|
// Safety limit to prevent infinite loops
|
||||||
|
if offset > 500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &ArtistResponsePayload{
|
||||||
|
ArtistInfo: artistInfo,
|
||||||
|
Albums: albums,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(artistCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
||||||
var data struct {
|
var data struct {
|
||||||
ExternalID externalID `json:"external_ids"`
|
ExternalID externalID `json:"external_ids"`
|
||||||
@@ -465,8 +904,16 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set headers (same as PC version baseHeaders)
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
req.Header.Set("sec-ch-ua-platform", "\"Windows\"")
|
||||||
|
req.Header.Set("sec-fetch-dest", "empty")
|
||||||
|
req.Header.Set("sec-fetch-mode", "cors")
|
||||||
|
req.Header.Set("sec-fetch-site", "same-origin")
|
||||||
|
req.Header.Set("Referer", "https://open.spotify.com/")
|
||||||
|
req.Header.Set("Origin", "https://open.spotify.com")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
@@ -493,13 +940,23 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
c.rngMu.Lock()
|
c.rngMu.Lock()
|
||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
chromeMajor := 80 + c.rng.Intn(25)
|
// Use Mac User-Agent format (same as PC version)
|
||||||
chromeBuild := 3000 + c.rng.Intn(1500)
|
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||||
chromePatch := 60 + c.rng.Intn(65)
|
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||||
|
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
|
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||||
|
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||||
|
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||||
|
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||||
|
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
|
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||||
|
macMajor, macMinor,
|
||||||
|
webkitMajor, webkitMinor,
|
||||||
chromeMajor, chromeBuild, chromePatch,
|
chromeMajor, chromeBuild, chromePatch,
|
||||||
|
safariMajor, safariMinor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -427,7 +427,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "searchSpotifyAll":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let query = args["query"] as! String
|
||||||
|
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||||
|
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||||
|
let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -90,6 +99,28 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendGetDownloadProgress()
|
let response = GobackendGetDownloadProgress()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "getAllDownloadProgress":
|
||||||
|
let response = GobackendGetAllDownloadProgress()
|
||||||
|
return response
|
||||||
|
|
||||||
|
case "initItemProgress":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendInitItemProgress(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "finishItemProgress":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendFinishItemProgress(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "clearItemProgress":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendClearItemProgress(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "setDownloadDirectory":
|
case "setDownloadDirectory":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let path = args["path"] as! String
|
let path = args["path"] as! String
|
||||||
@@ -133,7 +164,8 @@ import Gobackend // Import Go framework
|
|||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, &error)
|
let filePath = args["file_path"] as? String ?? ""
|
||||||
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -145,6 +177,71 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "cleanupConnections":
|
||||||
|
GobackendCleanupConnections()
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "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
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "SpotiFLAC",
|
domain: "SpotiFLAC",
|
||||||
|
|||||||
@@ -1,122 +1 @@
|
|||||||
{
|
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 318 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 744 B |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 419 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 789 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 576 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 752 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -7,10 +7,11 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
final settings = ref.watch(settingsProvider);
|
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
||||||
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: settings.isFirstLaunch ? '/setup' : '/',
|
initialLocation: isFirstLaunch ? '/setup' : '/',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/// App version and info constants
|
||||||
|
/// Update version here only - all other files will reference this
|
||||||
|
class AppInfo {
|
||||||
|
static const String version = '2.1.7';
|
||||||
|
static const String buildNumber = '45';
|
||||||
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
static const String appName = 'SpotiFLAC';
|
||||||
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
|
static const String mobileAuthor = 'zarzet';
|
||||||
|
static const String originalAuthor = 'afkarxyz';
|
||||||
|
|
||||||
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
|
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||||
|
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||||
|
|
||||||
|
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
|
}
|
||||||
@@ -1,12 +1,37 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/app.dart';
|
import 'package:spotiflac_android/app.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize notification service
|
||||||
|
await NotificationService().initialize();
|
||||||
|
|
||||||
|
// Initialize share intent service
|
||||||
|
await ShareIntentService().initialize();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: SpotiFLACApp(),
|
child: const _EagerInitialization(
|
||||||
|
child: SpotiFLACApp(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Widget to eagerly initialize providers that need to load data on startup
|
||||||
|
class _EagerInitialization extends ConsumerWidget {
|
||||||
|
const _EagerInitialization({required this.child});
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Eagerly initialize download history provider to load from storage
|
||||||
|
ref.watch(downloadHistoryProvider);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,20 @@ part 'download_item.g.dart';
|
|||||||
enum DownloadStatus {
|
enum DownloadStatus {
|
||||||
queued,
|
queued,
|
||||||
downloading,
|
downloading,
|
||||||
|
finalizing, // Embedding metadata, cover, lyrics
|
||||||
completed,
|
completed,
|
||||||
failed,
|
failed,
|
||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error type enum for better error handling
|
||||||
|
enum DownloadErrorType {
|
||||||
|
unknown,
|
||||||
|
notFound, // Track not found on any service
|
||||||
|
rateLimit, // Rate limited by service
|
||||||
|
network, // Network/connection error
|
||||||
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class DownloadItem {
|
class DownloadItem {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -19,9 +28,12 @@ class DownloadItem {
|
|||||||
final String service;
|
final String service;
|
||||||
final DownloadStatus status;
|
final DownloadStatus status;
|
||||||
final double progress;
|
final double progress;
|
||||||
|
final double speedMBps; // Download speed in MB/s
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final DownloadErrorType? errorType;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
|
||||||
const DownloadItem({
|
const DownloadItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -29,9 +41,12 @@ class DownloadItem {
|
|||||||
required this.service,
|
required this.service,
|
||||||
this.status = DownloadStatus.queued,
|
this.status = DownloadStatus.queued,
|
||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
|
this.speedMBps = 0.0,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.errorType,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.qualityOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
DownloadItem copyWith({
|
DownloadItem copyWith({
|
||||||
@@ -40,9 +55,12 @@ class DownloadItem {
|
|||||||
String? service,
|
String? service,
|
||||||
DownloadStatus? status,
|
DownloadStatus? status,
|
||||||
double? progress,
|
double? progress,
|
||||||
|
double? speedMBps,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
|
DownloadErrorType? errorType,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
String? qualityOverride,
|
||||||
}) {
|
}) {
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -50,12 +68,31 @@ class DownloadItem {
|
|||||||
service: service ?? this.service,
|
service: service ?? this.service,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
progress: progress ?? this.progress,
|
progress: progress ?? this.progress,
|
||||||
|
speedMBps: speedMBps ?? this.speedMBps,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
|
errorType: errorType ?? this.errorType,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get user-friendly error message based on error type
|
||||||
|
String get errorMessage {
|
||||||
|
if (error == null) return '';
|
||||||
|
|
||||||
|
switch (errorType) {
|
||||||
|
case DownloadErrorType.notFound:
|
||||||
|
return 'Song not found on any service';
|
||||||
|
case DownloadErrorType.rateLimit:
|
||||||
|
return 'Rate limit reached, try again later';
|
||||||
|
case DownloadErrorType.network:
|
||||||
|
return 'Connection failed, check your internet';
|
||||||
|
default:
|
||||||
|
return error ?? 'An error occurred';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
||||||
_$DownloadItemFromJson(json);
|
_$DownloadItemFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
||||||
|
|||||||
@@ -7,52 +7,48 @@ part of 'download_item.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
track: Track.fromJson(json['track'] as Map<String, dynamic>),
|
track: Track.fromJson(json['track'] as Map<String, dynamic>),
|
||||||
service: json['service'] as String,
|
service: json['service'] as String,
|
||||||
status: $enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
status:
|
||||||
DownloadStatus.queued,
|
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
DownloadStatus.queued,
|
||||||
filePath: json['filePath'] as String?,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
error: json['error'] as String?,
|
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
filePath: json['filePath'] as String?,
|
||||||
);
|
error: json['error'] as String?,
|
||||||
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'track': instance.track.toJson(),
|
'track': instance.track,
|
||||||
'service': instance.service,
|
'service': instance.service,
|
||||||
'status': _$DownloadStatusEnumMap[instance.status]!,
|
'status': _$DownloadStatusEnumMap[instance.status]!,
|
||||||
'progress': instance.progress,
|
'progress': instance.progress,
|
||||||
|
'speedMBps': instance.speedMBps,
|
||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
|
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
|
'qualityOverride': instance.qualityOverride,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$DownloadStatusEnumMap = {
|
const _$DownloadStatusEnumMap = {
|
||||||
DownloadStatus.queued: 'queued',
|
DownloadStatus.queued: 'queued',
|
||||||
DownloadStatus.downloading: 'downloading',
|
DownloadStatus.downloading: 'downloading',
|
||||||
|
DownloadStatus.finalizing: 'finalizing',
|
||||||
DownloadStatus.completed: 'completed',
|
DownloadStatus.completed: 'completed',
|
||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
DownloadStatus.skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
K? $enumDecodeNullable<K, V>(
|
const _$DownloadErrorTypeEnumMap = {
|
||||||
Map<K, V> enumValues,
|
DownloadErrorType.unknown: 'unknown',
|
||||||
Object? source, {
|
DownloadErrorType.notFound: 'notFound',
|
||||||
K? unknownValue,
|
DownloadErrorType.rateLimit: 'rateLimit',
|
||||||
}) {
|
DownloadErrorType.network: 'network',
|
||||||
if (source == null) {
|
};
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return enumValues.entries
|
|
||||||
.singleWhere(
|
|
||||||
(e) => e.value == source,
|
|
||||||
orElse: () => throw ArgumentError(
|
|
||||||
'`$source` is not one of the supported values: '
|
|
||||||
'${enumValues.values.join(', ')}',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.key;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ class AppSettings {
|
|||||||
final bool embedLyrics;
|
final bool embedLyrics;
|
||||||
final bool maxQualityCover;
|
final bool maxQualityCover;
|
||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
|
final int concurrentDownloads; // 1 = sequential (default), max 3
|
||||||
|
final bool checkForUpdates; // Check for updates on app start
|
||||||
|
final String updateChannel; // stable, preview
|
||||||
|
final bool hasSearchedBefore; // Hide helper text after first search
|
||||||
|
final String folderOrganization; // none, artist, album, artist_album
|
||||||
|
final String historyViewMode; // list, grid
|
||||||
|
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||||
|
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||||
|
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||||
|
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||||
|
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'qobuz',
|
||||||
this.audioQuality = 'LOSSLESS',
|
this.audioQuality = 'LOSSLESS',
|
||||||
this.filenameFormat = '{title} - {artist}',
|
this.filenameFormat = '{title} - {artist}',
|
||||||
this.downloadDirectory = '',
|
this.downloadDirectory = '',
|
||||||
@@ -22,6 +33,17 @@ class AppSettings {
|
|||||||
this.embedLyrics = true,
|
this.embedLyrics = true,
|
||||||
this.maxQualityCover = true,
|
this.maxQualityCover = true,
|
||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
|
this.concurrentDownloads = 1, // Default: sequential (off)
|
||||||
|
this.checkForUpdates = true, // Default: enabled
|
||||||
|
this.updateChannel = 'stable', // Default: stable releases only
|
||||||
|
this.hasSearchedBefore = false, // Default: show helper text
|
||||||
|
this.folderOrganization = 'none', // Default: no folder organization
|
||||||
|
this.historyViewMode = 'grid', // Default: grid view
|
||||||
|
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||||
|
this.spotifyClientId = '', // Default: use built-in credentials
|
||||||
|
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||||
|
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||||
|
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -33,6 +55,17 @@ class AppSettings {
|
|||||||
bool? embedLyrics,
|
bool? embedLyrics,
|
||||||
bool? maxQualityCover,
|
bool? maxQualityCover,
|
||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
|
int? concurrentDownloads,
|
||||||
|
bool? checkForUpdates,
|
||||||
|
String? updateChannel,
|
||||||
|
bool? hasSearchedBefore,
|
||||||
|
String? folderOrganization,
|
||||||
|
String? historyViewMode,
|
||||||
|
bool? askQualityBeforeDownload,
|
||||||
|
String? spotifyClientId,
|
||||||
|
String? spotifyClientSecret,
|
||||||
|
bool? useCustomSpotifyCredentials,
|
||||||
|
String? metadataSource,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -43,6 +76,17 @@ class AppSettings {
|
|||||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||||
|
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||||
|
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||||
|
updateChannel: updateChannel ?? this.updateChannel,
|
||||||
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
|
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||||
|
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||||
|
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||||
|
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||||
|
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||||
|
metadataSource: metadataSource ?? this.metadataSource,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,27 @@ part of 'settings.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||||
defaultService: json['defaultService'] as String? ?? 'tidal',
|
defaultService: json['defaultService'] as String? ?? 'tidal',
|
||||||
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
|
||||||
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
|
||||||
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
downloadDirectory: json['downloadDirectory'] as String? ?? '',
|
||||||
autoFallback: json['autoFallback'] as bool? ?? true,
|
autoFallback: json['autoFallback'] as bool? ?? true,
|
||||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
);
|
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||||
|
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||||
|
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||||
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
|
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||||
|
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||||
|
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||||
|
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||||
|
useCustomSpotifyCredentials:
|
||||||
|
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||||
|
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -27,4 +39,15 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'embedLyrics': instance.embedLyrics,
|
'embedLyrics': instance.embedLyrics,
|
||||||
'maxQualityCover': instance.maxQualityCover,
|
'maxQualityCover': instance.maxQualityCover,
|
||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
|
'checkForUpdates': instance.checkForUpdates,
|
||||||
|
'updateChannel': instance.updateChannel,
|
||||||
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
|
'folderOrganization': instance.folderOrganization,
|
||||||
|
'historyViewMode': instance.historyViewMode,
|
||||||
|
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||||
|
'spotifyClientId': instance.spotifyClientId,
|
||||||
|
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||||
|
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||||
|
'metadataSource': instance.metadataSource,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
const String kThemeModeKey = 'theme_mode';
|
const String kThemeModeKey = 'theme_mode';
|
||||||
const String kUseDynamicColorKey = 'use_dynamic_color';
|
const String kUseDynamicColorKey = 'use_dynamic_color';
|
||||||
const String kSeedColorKey = 'seed_color';
|
const String kSeedColorKey = 'seed_color';
|
||||||
|
const String kUseAmoledKey = 'use_amoled';
|
||||||
|
|
||||||
/// Default Spotify green color for fallback
|
/// Default Spotify green color for fallback
|
||||||
const int kDefaultSeedColor = 0xFF1DB954;
|
const int kDefaultSeedColor = 0xFF1DB954;
|
||||||
@@ -13,11 +14,13 @@ class ThemeSettings {
|
|||||||
final ThemeMode themeMode;
|
final ThemeMode themeMode;
|
||||||
final bool useDynamicColor;
|
final bool useDynamicColor;
|
||||||
final int seedColorValue;
|
final int seedColorValue;
|
||||||
|
final bool useAmoled; // Pure black background for OLED screens
|
||||||
|
|
||||||
const ThemeSettings({
|
const ThemeSettings({
|
||||||
this.themeMode = ThemeMode.system,
|
this.themeMode = ThemeMode.system,
|
||||||
this.useDynamicColor = true,
|
this.useDynamicColor = true,
|
||||||
this.seedColorValue = kDefaultSeedColor,
|
this.seedColorValue = kDefaultSeedColor,
|
||||||
|
this.useAmoled = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Get seed color as Color object
|
/// Get seed color as Color object
|
||||||
@@ -28,11 +31,13 @@ class ThemeSettings {
|
|||||||
ThemeMode? themeMode,
|
ThemeMode? themeMode,
|
||||||
bool? useDynamicColor,
|
bool? useDynamicColor,
|
||||||
int? seedColorValue,
|
int? seedColorValue,
|
||||||
|
bool? useAmoled,
|
||||||
}) {
|
}) {
|
||||||
return ThemeSettings(
|
return ThemeSettings(
|
||||||
themeMode: themeMode ?? this.themeMode,
|
themeMode: themeMode ?? this.themeMode,
|
||||||
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
|
||||||
seedColorValue: seedColorValue ?? this.seedColorValue,
|
seedColorValue: seedColorValue ?? this.seedColorValue,
|
||||||
|
useAmoled: useAmoled ?? this.useAmoled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ class ThemeSettings {
|
|||||||
kThemeModeKey: themeMode.name,
|
kThemeModeKey: themeMode.name,
|
||||||
kUseDynamicColorKey: useDynamicColor,
|
kUseDynamicColorKey: useDynamicColor,
|
||||||
kSeedColorKey: seedColorValue,
|
kSeedColorKey: seedColorValue,
|
||||||
|
kUseAmoledKey: useAmoled,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Create from JSON map
|
/// Create from JSON map
|
||||||
@@ -49,6 +55,7 @@ class ThemeSettings {
|
|||||||
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
|
||||||
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
|
||||||
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
|
||||||
|
useAmoled: json[kUseAmoledKey] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,12 +65,13 @@ class ThemeSettings {
|
|||||||
return other is ThemeSettings &&
|
return other is ThemeSettings &&
|
||||||
other.themeMode == themeMode &&
|
other.themeMode == themeMode &&
|
||||||
other.useDynamicColor == useDynamicColor &&
|
other.useDynamicColor == useDynamicColor &&
|
||||||
other.seedColorValue == seedColorValue;
|
other.seedColorValue == seedColorValue &&
|
||||||
|
other.useAmoled == useAmoled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode;
|
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to convert string to ThemeMode
|
/// Helper to convert string to ThemeMode
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Track {
|
|||||||
final int? trackNumber;
|
final int? trackNumber;
|
||||||
final int? discNumber;
|
final int? discNumber;
|
||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
|
final String? deezerId;
|
||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
@@ -30,6 +31,7 @@ class Track {
|
|||||||
this.trackNumber,
|
this.trackNumber,
|
||||||
this.discNumber,
|
this.discNumber,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
|
this.deezerId,
|
||||||
this.availability,
|
this.availability,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,17 +44,23 @@ class ServiceAvailability {
|
|||||||
final bool tidal;
|
final bool tidal;
|
||||||
final bool qobuz;
|
final bool qobuz;
|
||||||
final bool amazon;
|
final bool amazon;
|
||||||
|
final bool deezer;
|
||||||
final String? tidalUrl;
|
final String? tidalUrl;
|
||||||
final String? qobuzUrl;
|
final String? qobuzUrl;
|
||||||
final String? amazonUrl;
|
final String? amazonUrl;
|
||||||
|
final String? deezerUrl;
|
||||||
|
final String? deezerId;
|
||||||
|
|
||||||
const ServiceAvailability({
|
const ServiceAvailability({
|
||||||
this.tidal = false,
|
this.tidal = false,
|
||||||
this.qobuz = false,
|
this.qobuz = false,
|
||||||
this.amazon = false,
|
this.amazon = false,
|
||||||
|
this.deezer = false,
|
||||||
this.tidalUrl,
|
this.tidalUrl,
|
||||||
this.qobuzUrl,
|
this.qobuzUrl,
|
||||||
this.amazonUrl,
|
this.amazonUrl,
|
||||||
|
this.deezerUrl,
|
||||||
|
this.deezerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -7,55 +7,64 @@ part of 'track.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
artistName: json['artistName'] as String,
|
artistName: json['artistName'] as String,
|
||||||
albumName: json['albumName'] as String,
|
albumName: json['albumName'] as String,
|
||||||
albumArtist: json['albumArtist'] as String?,
|
albumArtist: json['albumArtist'] as String?,
|
||||||
coverUrl: json['coverUrl'] as String?,
|
coverUrl: json['coverUrl'] as String?,
|
||||||
isrc: json['isrc'] as String?,
|
isrc: json['isrc'] as String?,
|
||||||
duration: (json['duration'] as num).toInt(),
|
duration: (json['duration'] as num).toInt(),
|
||||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
availability: json['availability'] == null
|
deezerId: json['deezerId'] as String?,
|
||||||
? null
|
availability: json['availability'] == null
|
||||||
: ServiceAvailability.fromJson(
|
? null
|
||||||
json['availability'] as Map<String, dynamic>),
|
: ServiceAvailability.fromJson(
|
||||||
);
|
json['availability'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'artistName': instance.artistName,
|
'artistName': instance.artistName,
|
||||||
'albumName': instance.albumName,
|
'albumName': instance.albumName,
|
||||||
'albumArtist': instance.albumArtist,
|
'albumArtist': instance.albumArtist,
|
||||||
'coverUrl': instance.coverUrl,
|
'coverUrl': instance.coverUrl,
|
||||||
'isrc': instance.isrc,
|
'isrc': instance.isrc,
|
||||||
'duration': instance.duration,
|
'duration': instance.duration,
|
||||||
'trackNumber': instance.trackNumber,
|
'trackNumber': instance.trackNumber,
|
||||||
'discNumber': instance.discNumber,
|
'discNumber': instance.discNumber,
|
||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
'availability': instance.availability?.toJson(),
|
'deezerId': instance.deezerId,
|
||||||
};
|
'availability': instance.availability,
|
||||||
|
};
|
||||||
|
|
||||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||||
ServiceAvailability(
|
ServiceAvailability(
|
||||||
tidal: json['tidal'] as bool? ?? false,
|
tidal: json['tidal'] as bool? ?? false,
|
||||||
qobuz: json['qobuz'] as bool? ?? false,
|
qobuz: json['qobuz'] as bool? ?? false,
|
||||||
amazon: json['amazon'] as bool? ?? false,
|
amazon: json['amazon'] as bool? ?? false,
|
||||||
|
deezer: json['deezer'] as bool? ?? false,
|
||||||
tidalUrl: json['tidalUrl'] as String?,
|
tidalUrl: json['tidalUrl'] as String?,
|
||||||
qobuzUrl: json['qobuzUrl'] as String?,
|
qobuzUrl: json['qobuzUrl'] as String?,
|
||||||
amazonUrl: json['amazonUrl'] as String?,
|
amazonUrl: json['amazonUrl'] as String?,
|
||||||
|
deezerUrl: json['deezerUrl'] as String?,
|
||||||
|
deezerId: json['deezerId'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
||||||
ServiceAvailability instance) =>
|
ServiceAvailability instance,
|
||||||
<String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'tidal': instance.tidal,
|
'tidal': instance.tidal,
|
||||||
'qobuz': instance.qobuz,
|
'qobuz': instance.qobuz,
|
||||||
'amazon': instance.amazon,
|
'amazon': instance.amazon,
|
||||||
'tidalUrl': instance.tidalUrl,
|
'deezer': instance.deezer,
|
||||||
'qobuzUrl': instance.qobuzUrl,
|
'tidalUrl': instance.tidalUrl,
|
||||||
'amazonUrl': instance.amazonUrl,
|
'qobuzUrl': instance.qobuzUrl,
|
||||||
};
|
'amazonUrl': instance.amazonUrl,
|
||||||
|
'deezerUrl': instance.deezerUrl,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import 'dart:convert';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
|
const _currentMigrationVersion = 1;
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
@override
|
@override
|
||||||
@@ -17,6 +20,32 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
|
||||||
|
// Run migrations if needed
|
||||||
|
await _runMigrations(prefs);
|
||||||
|
|
||||||
|
// Apply Spotify credentials to Go backend on load
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one-time migrations for settings
|
||||||
|
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||||
|
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||||
|
|
||||||
|
if (lastMigration < 1) {
|
||||||
|
// Migration 1: Set metadataSource to 'deezer' for existing users
|
||||||
|
// Only apply if user hasn't enabled custom Spotify credentials
|
||||||
|
// (users with custom credentials likely prefer Spotify)
|
||||||
|
if (!state.useCustomSpotifyCredentials) {
|
||||||
|
state = state.copyWith(metadataSource: 'deezer');
|
||||||
|
await _saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current migration version
|
||||||
|
if (lastMigration < _currentMigrationVersion) {
|
||||||
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +54,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
await prefs.setString(_settingsKey, jsonEncode(state.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply current Spotify credentials to Go backend
|
||||||
|
Future<void> _applySpotifyCredentials() async {
|
||||||
|
// Only apply custom credentials if enabled and both fields are set
|
||||||
|
if (state.useCustomSpotifyCredentials &&
|
||||||
|
state.spotifyClientId.isNotEmpty &&
|
||||||
|
state.spotifyClientSecret.isNotEmpty) {
|
||||||
|
await PlatformBridge.setSpotifyCredentials(
|
||||||
|
state.spotifyClientId,
|
||||||
|
state.spotifyClientSecret,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Clear to use default
|
||||||
|
await PlatformBridge.setSpotifyCredentials('', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setDefaultService(String service) {
|
void setDefaultService(String service) {
|
||||||
state = state.copyWith(defaultService: service);
|
state = state.copyWith(defaultService: service);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -64,6 +109,84 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(isFirstLaunch: false);
|
state = state.copyWith(isFirstLaunch: false);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setConcurrentDownloads(int count) {
|
||||||
|
// Clamp between 1 and 3
|
||||||
|
final clamped = count.clamp(1, 3);
|
||||||
|
state = state.copyWith(concurrentDownloads: clamped);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCheckForUpdates(bool enabled) {
|
||||||
|
state = state.copyWith(checkForUpdates: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUpdateChannel(String channel) {
|
||||||
|
state = state.copyWith(updateChannel: channel);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setHasSearchedBefore() {
|
||||||
|
if (!state.hasSearchedBefore) {
|
||||||
|
state = state.copyWith(hasSearchedBefore: true);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFolderOrganization(String organization) {
|
||||||
|
state = state.copyWith(folderOrganization: organization);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setHistoryViewMode(String mode) {
|
||||||
|
state = state.copyWith(historyViewMode: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAskQualityBeforeDownload(bool enabled) {
|
||||||
|
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyClientId(String clientId) {
|
||||||
|
state = state.copyWith(spotifyClientId: clientId);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyClientSecret(String clientSecret) {
|
||||||
|
state = state.copyWith(spotifyClientSecret: clientSecret);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSpotifyCredentials(String clientId, String clientSecret) {
|
||||||
|
state = state.copyWith(
|
||||||
|
spotifyClientId: clientId,
|
||||||
|
spotifyClientSecret: clientSecret,
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSpotifyCredentials() {
|
||||||
|
state = state.copyWith(
|
||||||
|
spotifyClientId: '',
|
||||||
|
spotifyClientSecret: '',
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setUseCustomSpotifyCredentials(bool enabled) {
|
||||||
|
state = state.copyWith(useCustomSpotifyCredentials: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
_applySpotifyCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMetadataSource(String source) {
|
||||||
|
state = state.copyWith(metadataSource: source);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -6,107 +6,243 @@ class TrackState {
|
|||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final String? albumId;
|
||||||
final String? albumName;
|
final String? albumName;
|
||||||
final String? playlistName;
|
final String? playlistName;
|
||||||
|
final String? artistId;
|
||||||
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
|
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
|
final bool hasSearchText; // For back button handling
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.albumId,
|
||||||
this.albumName,
|
this.albumName,
|
||||||
this.playlistName,
|
this.playlistName,
|
||||||
|
this.artistId,
|
||||||
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.artistAlbums,
|
||||||
|
this.searchArtists,
|
||||||
|
this.hasSearchText = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty);
|
||||||
|
|
||||||
TrackState copyWith({
|
TrackState copyWith({
|
||||||
List<Track>? tracks,
|
List<Track>? tracks,
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
String? error,
|
String? error,
|
||||||
|
String? albumId,
|
||||||
String? albumName,
|
String? albumName,
|
||||||
String? playlistName,
|
String? playlistName,
|
||||||
|
String? artistId,
|
||||||
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
|
List<ArtistAlbum>? artistAlbums,
|
||||||
|
List<SearchArtist>? searchArtists,
|
||||||
|
bool? hasSearchText,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
tracks: tracks ?? this.tracks,
|
tracks: tracks ?? this.tracks,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
|
albumId: albumId ?? this.albumId,
|
||||||
albumName: albumName ?? this.albumName,
|
albumName: albumName ?? this.albumName,
|
||||||
playlistName: playlistName ?? this.playlistName,
|
playlistName: playlistName ?? this.playlistName,
|
||||||
|
artistId: artistId ?? this.artistId,
|
||||||
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents an album in artist discography
|
||||||
|
class ArtistAlbum {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String releaseDate;
|
||||||
|
final int totalTracks;
|
||||||
|
final String? coverUrl;
|
||||||
|
final String albumType; // album, single, compilation
|
||||||
|
final String artists;
|
||||||
|
|
||||||
|
const ArtistAlbum({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.releaseDate,
|
||||||
|
required this.totalTracks,
|
||||||
|
this.coverUrl,
|
||||||
|
required this.albumType,
|
||||||
|
required this.artists,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an artist in search results
|
||||||
|
class SearchArtist {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? imageUrl;
|
||||||
|
final int followers;
|
||||||
|
final int popularity;
|
||||||
|
|
||||||
|
const SearchArtist({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.followers,
|
||||||
|
required this.popularity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class TrackNotifier extends Notifier<TrackState> {
|
class TrackNotifier extends Notifier<TrackState> {
|
||||||
|
/// Request ID to track and cancel outdated requests
|
||||||
|
int _currentRequestId = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TrackState build() {
|
TrackState build() {
|
||||||
return const TrackState();
|
return const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url) async {
|
/// Check if request is still valid (not cancelled by newer request)
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
|
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||||
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
|
// Preserve hasSearchText during fetch
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
// Use the new fallback-enabled method
|
||||||
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
|
||||||
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[FetchURL] Metadata fetch success');
|
||||||
|
} catch (e) {
|
||||||
|
// If fallback also fails, show error
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[FetchURL] Metadata fetch failed: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
if (type == 'track') {
|
if (type == 'track') {
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
final track = _parseTrack(trackData);
|
final track = _parseTrack(trackData);
|
||||||
state = state.copyWith(
|
state = TrackState(
|
||||||
tracks: [track],
|
tracks: [track],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumName: null,
|
|
||||||
playlistName: null,
|
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
);
|
);
|
||||||
} else if (type == 'album') {
|
} else if (type == 'album') {
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
state = state.copyWith(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
albumId: parsed['id'] as String?,
|
||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
playlistName: null,
|
|
||||||
coverUrl: albumInfo['images'] as String?,
|
coverUrl: albumInfo['images'] as String?,
|
||||||
);
|
);
|
||||||
|
// Pre-warm cache for album tracks in background
|
||||||
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||||
state = state.copyWith(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumName: null,
|
|
||||||
playlistName: owner?['name'] as String?,
|
playlistName: owner?['name'] as String?,
|
||||||
coverUrl: owner?['images'] as String?,
|
coverUrl: owner?['images'] as String?,
|
||||||
);
|
);
|
||||||
|
// Pre-warm cache for playlist tracks in background
|
||||||
|
_preWarmCacheForTracks(tracks);
|
||||||
|
} else if (type == 'artist') {
|
||||||
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
|
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
state = TrackState(
|
||||||
|
tracks: [], // No tracks for artist view
|
||||||
|
isLoading: false,
|
||||||
|
artistId: artistInfo['id'] as String?,
|
||||||
|
artistName: artistInfo['name'] as String?,
|
||||||
|
coverUrl: artistInfo['images'] as String?,
|
||||||
|
artistAlbums: albums,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(isLoading: false, error: e.toString());
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
// Preserve hasSearchText on error so user stays on search screen
|
||||||
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query) async {
|
Future<void> search(String query, {String? metadataSource}) async {
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
// Increment request ID to cancel any pending requests
|
||||||
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
|
// Preserve hasSearchText during search
|
||||||
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await PlatformBridge.searchSpotify(query, limit: 20);
|
// Use Deezer or Spotify based on settings
|
||||||
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>)).toList();
|
||||||
state = state.copyWith(
|
final artists = artistList.map((a) => _parseSearchArtist(a as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
|
searchArtists: artists,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumName: null,
|
hasSearchText: state.hasSearchText,
|
||||||
playlistName: null,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(isLoading: false, error: e.toString());
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
// Preserve hasSearchText on error so user stays on search screen
|
||||||
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +288,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = const TrackState();
|
state = const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set search text state for back button handling
|
||||||
|
void setSearchText(bool hasText) {
|
||||||
|
state = state.copyWith(hasSearchText: hasText);
|
||||||
|
}
|
||||||
|
|
||||||
Track _parseTrack(Map<String, dynamic> data) {
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: data['spotify_id'] as String? ?? '',
|
||||||
@@ -161,7 +302,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
@@ -177,12 +318,56 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: data['images'] as String?,
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: data['duration_ms'] as int? ?? 0,
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
|
return ArtistAlbum(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
|
artists: data['artists'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchArtist _parseSearchArtist(Map<String, dynamic> data) {
|
||||||
|
return SearchArtist(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
imageUrl: data['images'] as String?,
|
||||||
|
followers: data['followers'] as int? ?? 0,
|
||||||
|
popularity: data['popularity'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-warm track ID cache for faster downloads
|
||||||
|
/// Runs in background, doesn't block UI
|
||||||
|
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||||
|
// Only pre-warm if we have tracks with ISRC
|
||||||
|
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||||
|
if (tracksWithIsrc.isEmpty) return;
|
||||||
|
|
||||||
|
// Build request list for Go backend
|
||||||
|
final cacheRequests = tracksWithIsrc.map((t) => {
|
||||||
|
'isrc': t.isrc!,
|
||||||
|
'track_name': t.name,
|
||||||
|
'artist_name': t.artistName,
|
||||||
|
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
||||||
|
'service': 'tidal', // Default to tidal for pre-warming
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Fire and forget - runs in background
|
||||||
|
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
|
||||||
|
// Silently ignore errors - this is just an optimization
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
|
||||||
|
|||||||
@@ -0,0 +1,738 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
|
/// Simple in-memory cache for album tracks
|
||||||
|
class _AlbumCache {
|
||||||
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
|
static List<Track>? get(String albumId) {
|
||||||
|
final entry = _cache[albumId];
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
|
_cache.remove(albumId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set(String albumId, List<Track> tracks) {
|
||||||
|
_cache[albumId] = _CacheEntry(tracks, DateTime.now().add(_ttl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheEntry {
|
||||||
|
final List<Track> tracks;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
_CacheEntry(this.tracks, this.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album detail screen with Material Expressive 3 design
|
||||||
|
class AlbumScreen extends ConsumerStatefulWidget {
|
||||||
|
final String albumId;
|
||||||
|
final String albumName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<Track>? tracks; // Optional - will fetch if null
|
||||||
|
|
||||||
|
const AlbumScreen({
|
||||||
|
super.key,
|
||||||
|
required this.albumId,
|
||||||
|
required this.albumName,
|
||||||
|
this.coverUrl,
|
||||||
|
this.tracks,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||||
|
List<Track>? _tracks;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Priority: widget.tracks > cache > fetch
|
||||||
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
|
if (_tracks == null) {
|
||||||
|
_fetchTracks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchTracks() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
|
// Check if this is a Deezer album ID (format: "deezer:123456")
|
||||||
|
if (widget.albumId.startsWith('deezer:')) {
|
||||||
|
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
||||||
|
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
||||||
|
} else {
|
||||||
|
// Spotify album - use fallback method
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
||||||
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
|
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
|
return Track(
|
||||||
|
id: data['spotify_id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
artistName: data['artists'] as String? ?? '',
|
||||||
|
albumName: data['album_name'] as String? ?? '',
|
||||||
|
albumArtist: data['album_artist'] as String?,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
isrc: data['isrc'] as String?,
|
||||||
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
|
trackNumber: data['track_number'] as int?,
|
||||||
|
discNumber: data['disc_number'] as int?,
|
||||||
|
releaseDate: data['release_date'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final tracks = _tracks ?? [];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme),
|
||||||
|
if (_isLoading)
|
||||||
|
const SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)),
|
||||||
|
if (_error != null)
|
||||||
|
SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
|
)),
|
||||||
|
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
||||||
|
_buildTrackListHeader(context, colorScheme),
|
||||||
|
_buildTrackList(context, colorScheme, tracks),
|
||||||
|
],
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.coverUrl != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
colorBlendMode: BlendMode.darken,
|
||||||
|
memCacheWidth: 600,
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||||
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final tracks = _tracks ?? [];
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.albumName,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (tracks.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (tracks.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _downloadAll(context),
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: Text('Download All (${tracks.length})'),
|
||||||
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _AlbumTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, track),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadTrack(BuildContext context, Track track) {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, (quality, service) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadAll(BuildContext context) {
|
||||||
|
final tracks = _tracks;
|
||||||
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, (quality, service) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
String selectedService = settings.defaultService;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) => SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (trackName != null) ...[
|
||||||
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
|
],
|
||||||
|
// Service selector
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
|
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
|
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build error widget with special handling for rate limit (429)
|
||||||
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
|
final isRateLimit = error.contains('429') ||
|
||||||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
|
if (isRateLimit) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Rate Limited',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Too many requests. Please wait a moment and try again.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error display
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QualityOption extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
|
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServiceChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
|
final String trackName;
|
||||||
|
final String? artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||||
|
bool _expanded = false;
|
||||||
|
bool _isOverflowing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||||
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||||
|
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||||
|
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||||
|
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||||
|
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _isOverflowing != titleOverflows) {
|
||||||
|
setState(() => _isOverflowing = titleOverflows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.trackName,
|
||||||
|
style: titleStyle,
|
||||||
|
maxLines: _expanded ? 10 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (widget.artistName != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.artistName!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: _expanded ? 3 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isOverflowing || _expanded)
|
||||||
|
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
class _AlbumTrackItem extends ConsumerWidget {
|
||||||
|
final Track track;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
|
||||||
|
const _AlbumTrackItem({required this.track, required this.onDownload});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Only watch the specific item for this track
|
||||||
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if track is in history (already downloaded before)
|
||||||
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
|
return state.isDownloaded(track.id);
|
||||||
|
}));
|
||||||
|
|
||||||
|
final isQueued = queueItem != null;
|
||||||
|
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||||
|
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||||
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
|
// Show as downloaded if in queue completed OR in history
|
||||||
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
leading: track.coverUrl != null
|
||||||
|
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||||
|
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||||
|
if (isQueued) return;
|
||||||
|
|
||||||
|
if (isInHistory) {
|
||||||
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||||
|
if (historyItem != null) {
|
||||||
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
|
if (fileExists) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||||
|
required bool isQueued,
|
||||||
|
required bool isDownloading,
|
||||||
|
required bool isFinalizing,
|
||||||
|
required bool showAsDownloaded,
|
||||||
|
required bool isInHistory,
|
||||||
|
required double progress,
|
||||||
|
}) {
|
||||||
|
const double size = 44.0;
|
||||||
|
const double iconSize = 20.0;
|
||||||
|
|
||||||
|
if (showAsDownloaded) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
} else if (isFinalizing) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isDownloading) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isQueued) {
|
||||||
|
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||||
|
} else {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onDownload,
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
|
|
||||||
|
/// Simple in-memory cache for artist discography
|
||||||
|
class _ArtistCache {
|
||||||
|
static final Map<String, _CacheEntry> _cache = {};
|
||||||
|
static const Duration _ttl = Duration(minutes: 10);
|
||||||
|
|
||||||
|
static List<ArtistAlbum>? get(String artistId) {
|
||||||
|
final entry = _cache[artistId];
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||||
|
_cache.remove(artistId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set(String artistId, List<ArtistAlbum> albums) {
|
||||||
|
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CacheEntry {
|
||||||
|
final List<ArtistAlbum> albums;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
_CacheEntry(this.albums, this.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artist screen with Material Expressive 3 design - shows discography
|
||||||
|
class ArtistScreen extends ConsumerStatefulWidget {
|
||||||
|
final String artistId;
|
||||||
|
final String artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<ArtistAlbum>? albums; // Optional - will fetch if null
|
||||||
|
|
||||||
|
const ArtistScreen({
|
||||||
|
super.key,
|
||||||
|
required this.artistId,
|
||||||
|
required this.artistName,
|
||||||
|
this.coverUrl,
|
||||||
|
this.albums,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||||
|
bool _isLoadingDiscography = false;
|
||||||
|
List<ArtistAlbum>? _albums;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Priority: widget.albums > cache > fetch
|
||||||
|
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
|
||||||
|
if (_albums == null) {
|
||||||
|
_fetchDiscography();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchDiscography() async {
|
||||||
|
setState(() => _isLoadingDiscography = true);
|
||||||
|
try {
|
||||||
|
List<ArtistAlbum> albums;
|
||||||
|
|
||||||
|
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||||
|
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
|
||||||
|
_ArtistCache.set(widget.artistId, albums);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_albums = albums;
|
||||||
|
_isLoadingDiscography = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoadingDiscography = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
|
return ArtistAlbum(
|
||||||
|
id: data['id'] as String? ?? '',
|
||||||
|
name: data['name'] as String? ?? '',
|
||||||
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
|
coverUrl: data['images'] as String?,
|
||||||
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
|
artists: data['artists'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final albums = _albums ?? [];
|
||||||
|
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
|
||||||
|
final singles = albums.where((a) => a.albumType == 'single').toList();
|
||||||
|
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, colorScheme),
|
||||||
|
if (_isLoadingDiscography)
|
||||||
|
const SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)),
|
||||||
|
if (_error != null)
|
||||||
|
SliverToBoxAdapter(child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildErrorWidget(_error!, colorScheme),
|
||||||
|
)),
|
||||||
|
if (!_isLoadingDiscography && _error == null) ...[
|
||||||
|
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
|
||||||
|
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
|
||||||
|
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
|
||||||
|
],
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (widget.coverUrl != null)
|
||||||
|
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||||
|
),
|
||||||
|
child: ClipOval(
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_albums != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.album, size: 14, color: colorScheme.onPrimaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumSection(String title, List<ArtistAlbum> albums, ColorScheme colorScheme) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.album, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 210,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final album = albums[index];
|
||||||
|
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _navigateToAlbum(album),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: album.coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
|
||||||
|
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
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),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToAlbum(ArtistAlbum album) {
|
||||||
|
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
|
||||||
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
Navigator.push(context, MaterialPageRoute(
|
||||||
|
builder: (context) => AlbumScreen(
|
||||||
|
albumId: album.id,
|
||||||
|
albumName: album.name,
|
||||||
|
coverUrl: album.coverUrl,
|
||||||
|
// tracks: null - will be fetched in AlbumScreen
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build error widget with special handling for rate limit (429)
|
||||||
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||||
|
final isRateLimit = error.contains('429') ||
|
||||||
|
error.toLowerCase().contains('rate limit') ||
|
||||||
|
error.toLowerCase().contains('too many requests');
|
||||||
|
|
||||||
|
if (isRateLimit) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Rate Limited',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Too many requests. Please wait a moment and try again.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error display
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: colorScheme.error),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:open_filex/open_filex.dart';
|
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
||||||
|
|
||||||
class HistoryScreen extends ConsumerWidget {
|
|
||||||
const HistoryScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final historyState = ref.watch(downloadHistoryProvider);
|
|
||||||
final history = historyState.items;
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Download History'),
|
|
||||||
actions: [
|
|
||||||
if (history.isNotEmpty)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete_sweep),
|
|
||||||
onPressed: () => _showClearHistoryDialog(context, ref),
|
|
||||||
tooltip: 'Clear history',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: history.isEmpty
|
|
||||||
? _buildEmptyState(context, colorScheme)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: history.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = history[index];
|
|
||||||
return _buildHistoryItem(context, ref, item, colorScheme);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.history,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No download history',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Downloaded tracks will appear here',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
final fileExists = File(item.filePath).existsSync();
|
|
||||||
|
|
||||||
return Dismissible(
|
|
||||||
key: Key(item.id),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
background: Container(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
color: colorScheme.error,
|
|
||||||
child: Icon(Icons.delete, color: colorScheme.onError),
|
|
||||||
),
|
|
||||||
onDismissed: (_) {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Removed "${item.trackName}" from history')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
leading: item.coverUrl != null
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, __) => Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
item.trackName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_getServiceIcon(item.service),
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatDate(item.downloadedAt),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!fileExists) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.warning,
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
'File missing',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: fileExists
|
|
||||||
? IconButton(
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
onPressed: () => _openFile(context, item.filePath),
|
|
||||||
)
|
|
||||||
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
|
|
||||||
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
|
|
||||||
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getServiceIcon(String service) {
|
|
||||||
switch (service.toLowerCase()) {
|
|
||||||
case 'tidal':
|
|
||||||
return Icons.waves;
|
|
||||||
case 'qobuz':
|
|
||||||
return Icons.album;
|
|
||||||
case 'amazon':
|
|
||||||
return Icons.shopping_cart;
|
|
||||||
default:
|
|
||||||
return Icons.cloud_download;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final diff = now.difference(date);
|
|
||||||
|
|
||||||
if (diff.inDays == 0) {
|
|
||||||
if (diff.inHours == 0) {
|
|
||||||
return '${diff.inMinutes}m ago';
|
|
||||||
}
|
|
||||||
return '${diff.inHours}h ago';
|
|
||||||
} else if (diff.inDays == 1) {
|
|
||||||
return 'Yesterday';
|
|
||||||
} else if (diff.inDays < 7) {
|
|
||||||
return '${diff.inDays}d ago';
|
|
||||||
} else {
|
|
||||||
return '${date.day}/${date.month}/${date.year}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
|
||||||
try {
|
|
||||||
final result = await OpenFilex.open(filePath);
|
|
||||||
|
|
||||||
if (result.type != ResultType.done) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Cannot open: ${result.message}'),
|
|
||||||
action: SnackBarAction(
|
|
||||||
label: 'Copy Path',
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(ClipboardData(text: filePath));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Path copied to clipboard')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Cannot open file: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (item.coverUrl != null)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.trackName,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.albumName,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(),
|
|
||||||
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
|
|
||||||
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
|
|
||||||
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.delete, color: colorScheme.error),
|
|
||||||
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
if (File(item.filePath).existsSync())
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_openFile(context, item.filePath);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 80,
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: isPath ? 12 : 14,
|
|
||||||
fontFamily: isPath ? 'monospace' : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Clear History'),
|
|
||||||
content: const Text(
|
|
||||||
'Are you sure you want to clear all download history? '
|
|
||||||
'This will not delete the downloaded files.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:open_filex/open_filex.dart';
|
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
||||||
|
|
||||||
class HistoryTab extends ConsumerWidget {
|
|
||||||
const HistoryTab({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final historyState = ref.watch(downloadHistoryProvider);
|
|
||||||
final history = historyState.items;
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
// Header with clear action
|
|
||||||
if (history.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${history.length} downloads',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () => _showClearHistoryDialog(context, ref),
|
|
||||||
icon: Icon(Icons.delete_sweep, size: 18, color: colorScheme.error),
|
|
||||||
label: Text('Clear history', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// History list
|
|
||||||
Expanded(
|
|
||||||
child: history.isEmpty
|
|
||||||
? _buildEmptyState(context, colorScheme)
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: history.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = history[index];
|
|
||||||
return _buildHistoryItem(context, ref, item, colorScheme);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.history,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'No download history',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Downloaded tracks will appear here',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHistoryItem(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
final fileExists = File(item.filePath).existsSync();
|
|
||||||
|
|
||||||
return Dismissible(
|
|
||||||
key: Key(item.id),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
background: Container(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
padding: const EdgeInsets.only(right: 16),
|
|
||||||
color: colorScheme.error,
|
|
||||||
child: Icon(Icons.delete, color: colorScheme.onError),
|
|
||||||
),
|
|
||||||
onDismissed: (_) {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Removed "${item.trackName}" from history')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: ListTile(
|
|
||||||
leading: item.coverUrl != null
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, __) => Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
item.trackName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_getServiceIcon(item.service),
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_formatDate(item.downloadedAt),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!fileExists) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.warning,
|
|
||||||
size: 12,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
'File missing',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: fileExists
|
|
||||||
? IconButton(
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
onPressed: () => _openFile(context, item.filePath),
|
|
||||||
)
|
|
||||||
: Icon(Icons.error_outline, color: colorScheme.onSurfaceVariant),
|
|
||||||
onTap: fileExists ? () => _openFile(context, item.filePath) : null,
|
|
||||||
onLongPress: () => _showItemDetails(context, ref, item, colorScheme),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getServiceIcon(String service) {
|
|
||||||
switch (service.toLowerCase()) {
|
|
||||||
case 'tidal':
|
|
||||||
return Icons.waves;
|
|
||||||
case 'qobuz':
|
|
||||||
return Icons.album;
|
|
||||||
case 'amazon':
|
|
||||||
return Icons.shopping_cart;
|
|
||||||
default:
|
|
||||||
return Icons.cloud_download;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final diff = now.difference(date);
|
|
||||||
|
|
||||||
if (diff.inDays == 0) {
|
|
||||||
if (diff.inHours == 0) {
|
|
||||||
return '${diff.inMinutes}m ago';
|
|
||||||
}
|
|
||||||
return '${diff.inHours}h ago';
|
|
||||||
} else if (diff.inDays == 1) {
|
|
||||||
return 'Yesterday';
|
|
||||||
} else if (diff.inDays < 7) {
|
|
||||||
return '${diff.inDays}d ago';
|
|
||||||
} else {
|
|
||||||
return '${date.day}/${date.month}/${date.year}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
|
||||||
try {
|
|
||||||
final result = await OpenFilex.open(filePath);
|
|
||||||
|
|
||||||
if (result.type != ResultType.done) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Cannot open: ${result.message}'),
|
|
||||||
action: SnackBarAction(
|
|
||||||
label: 'Copy Path',
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(ClipboardData(text: filePath));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Path copied to clipboard')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Cannot open file: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showItemDetails(BuildContext context, WidgetRef ref, DownloadHistoryItem item, ColorScheme colorScheme) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (item.coverUrl != null)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: item.coverUrl!,
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.trackName,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.artistName,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.albumName,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(),
|
|
||||||
_buildDetailRow(context, 'Service', item.service.toUpperCase(), colorScheme),
|
|
||||||
_buildDetailRow(context, 'Downloaded', _formatDate(item.downloadedAt), colorScheme),
|
|
||||||
_buildDetailRow(context, 'File', item.filePath, colorScheme, isPath: true),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.delete, color: colorScheme.error),
|
|
||||||
label: Text('Remove', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
if (File(item.filePath).existsSync())
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_openFile(context, item.filePath);
|
|
||||||
},
|
|
||||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
|
||||||
label: Text('Play', style: TextStyle(color: colorScheme.primary)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDetailRow(BuildContext context, String label, String value, ColorScheme colorScheme, {bool isPath = false}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 80,
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: isPath ? 12 : 14,
|
|
||||||
fontFamily: isPath ? 'monospace' : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showClearHistoryDialog(BuildContext context, WidgetRef ref) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Clear History'),
|
|
||||||
content: const Text(
|
|
||||||
'Are you sure you want to clear all download history? '
|
|
||||||
'This will not delete the downloaded files.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,9 +178,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
onDestinationSelected: _onNavTap,
|
onDestinationSelected: _onNavTap,
|
||||||
destinations: [
|
destinations: [
|
||||||
const NavigationDestination(
|
const NavigationDestination(
|
||||||
icon: Icon(Icons.home_outlined),
|
icon: Icon(Icons.search_outlined),
|
||||||
selectedIcon: Icon(Icons.home),
|
selectedIcon: Icon(Icons.search),
|
||||||
label: 'Home',
|
label: 'Search',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Badge(
|
icon: Badge(
|
||||||
@@ -219,7 +220,7 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, _) => Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/settings_tab.dart';
|
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||||
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/update_checker.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('MainShell');
|
||||||
|
|
||||||
class MainShell extends ConsumerStatefulWidget {
|
class MainShell extends ConsumerStatefulWidget {
|
||||||
const MainShell({super.key});
|
const MainShell({super.key});
|
||||||
@@ -15,90 +25,219 @@ class MainShell extends ConsumerStatefulWidget {
|
|||||||
class _MainShellState extends ConsumerState<MainShell> {
|
class _MainShellState extends ConsumerState<MainShell> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
|
bool _hasCheckedUpdate = false;
|
||||||
|
StreamSubscription<String>? _shareSubscription;
|
||||||
|
DateTime? _lastBackPress; // For double-tap to exit
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageController = PageController(initialPage: _currentIndex);
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
|
// Check for updates after first frame
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkForUpdates();
|
||||||
|
_setupShareListener();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupShareListener() {
|
||||||
|
// Check for pending URL that was received before listener was ready
|
||||||
|
final pendingUrl = ShareIntentService().consumePendingUrl();
|
||||||
|
if (pendingUrl != null) {
|
||||||
|
_log.d('Processing pending shared URL: $pendingUrl');
|
||||||
|
_handleSharedUrl(pendingUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for future shared URLs with error handling
|
||||||
|
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
||||||
|
(url) {
|
||||||
|
_log.d('Received shared URL from stream: $url');
|
||||||
|
_handleSharedUrl(url);
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
_log.e('Share stream error: $error');
|
||||||
|
},
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSharedUrl(String url) {
|
||||||
|
// Navigate to Home tab
|
||||||
|
if (_currentIndex != 0) {
|
||||||
|
_onNavTap(0);
|
||||||
|
}
|
||||||
|
// Fetch metadata for shared URL
|
||||||
|
ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
|
// Mark that user has searched (hide helper text)
|
||||||
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
|
// Show snackbar
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Loading shared link...')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkForUpdates() async {
|
||||||
|
if (_hasCheckedUpdate) return;
|
||||||
|
_hasCheckedUpdate = true;
|
||||||
|
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (!settings.checkForUpdates) return;
|
||||||
|
|
||||||
|
final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel);
|
||||||
|
if (updateInfo != null && mounted) {
|
||||||
|
showUpdateDialog(
|
||||||
|
context,
|
||||||
|
updateInfo: updateInfo,
|
||||||
|
onDisableUpdates: () {
|
||||||
|
ref.read(settingsProvider.notifier).setCheckForUpdates(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_shareSubscription?.cancel();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onNavTap(int index) {
|
void _onNavTap(int index) {
|
||||||
setState(() => _currentIndex = index);
|
if (_currentIndex != index) {
|
||||||
_pageController.animateToPage(
|
setState(() => _currentIndex = index);
|
||||||
index,
|
_pageController.animateToPage(
|
||||||
duration: const Duration(milliseconds: 300),
|
index,
|
||||||
curve: Curves.easeInOut,
|
duration: const Duration(milliseconds: 250),
|
||||||
);
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPageChanged(int index) {
|
void _onPageChanged(int index) {
|
||||||
setState(() => _currentIndex = index);
|
if (_currentIndex != index) {
|
||||||
|
setState(() => _currentIndex = index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle back press with double-tap to exit
|
||||||
|
void _handleBackPress() {
|
||||||
|
final trackState = ref.read(trackProvider);
|
||||||
|
|
||||||
|
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
||||||
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
if (isKeyboardVisible) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
||||||
|
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||||
|
ref.read(trackProvider.notifier).clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not on Home tab, go to Home tab first
|
||||||
|
if (_currentIndex != 0) {
|
||||||
|
_onNavTap(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If loading, ignore back press
|
||||||
|
if (trackState.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-tap to exit
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
||||||
|
SystemNavigator.pop();
|
||||||
|
} else {
|
||||||
|
_lastBackPress = now;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Press back again to exit'),
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final queueState = ref.watch(downloadQueueProvider);
|
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
|
final trackState = ref.watch(trackProvider);
|
||||||
|
|
||||||
|
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||||
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
|
||||||
|
// Determine if we can pop (for predictive back animation)
|
||||||
|
// canPop is true when we're at root with no content - enables predictive back gesture
|
||||||
|
// IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation
|
||||||
|
final canPop = _currentIndex == 0 &&
|
||||||
|
!trackState.hasSearchText &&
|
||||||
|
!trackState.hasContent &&
|
||||||
|
!trackState.isLoading &&
|
||||||
|
!isKeyboardVisible;
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
appBar: AppBar(
|
canPop: canPop,
|
||||||
leading: Padding(
|
onPopInvokedWithResult: (didPop, result) async {
|
||||||
padding: const EdgeInsets.all(8.0),
|
if (didPop) {
|
||||||
child: ClipRRect(
|
// System handled the pop - this means predictive back completed
|
||||||
borderRadius: BorderRadius.circular(20),
|
// We need to handle double-tap to exit here
|
||||||
child: Image.asset(
|
return;
|
||||||
'assets/images/logo.png',
|
}
|
||||||
width: 40,
|
|
||||||
height: 40,
|
// Handle back press manually when canPop is false
|
||||||
),
|
_handleBackPress();
|
||||||
),
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: PageView(
|
||||||
|
controller: _pageController,
|
||||||
|
onPageChanged: _onPageChanged,
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
children: const [
|
||||||
|
HomeTab(),
|
||||||
|
QueueTab(),
|
||||||
|
SettingsTab(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
title: const Text('SpotiFLAC'),
|
bottomNavigationBar: NavigationBar(
|
||||||
),
|
selectedIndex: _currentIndex,
|
||||||
body: PageView(
|
onDestinationSelected: _onNavTap,
|
||||||
controller: _pageController,
|
animationDuration: const Duration(milliseconds: 200),
|
||||||
onPageChanged: _onPageChanged,
|
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||||
physics: const BouncingScrollPhysics(),
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
|
||||||
children: const [
|
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
|
||||||
HomeTab(),
|
destinations: [
|
||||||
QueueTab(),
|
const NavigationDestination(
|
||||||
SettingsTab(),
|
icon: Icon(Icons.home_outlined),
|
||||||
],
|
selectedIcon: Icon(Icons.home),
|
||||||
),
|
label: 'Home',
|
||||||
bottomNavigationBar: NavigationBar(
|
|
||||||
selectedIndex: _currentIndex,
|
|
||||||
onDestinationSelected: _onNavTap,
|
|
||||||
animationDuration: const Duration(milliseconds: 300),
|
|
||||||
destinations: [
|
|
||||||
const NavigationDestination(
|
|
||||||
icon: Icon(Icons.home_outlined),
|
|
||||||
selectedIcon: Icon(Icons.home),
|
|
||||||
label: 'Home',
|
|
||||||
),
|
|
||||||
NavigationDestination(
|
|
||||||
icon: Badge(
|
|
||||||
isLabelVisible: queueState.queuedCount > 0,
|
|
||||||
label: Text('${queueState.queuedCount}'),
|
|
||||||
child: const Icon(Icons.download_outlined),
|
|
||||||
),
|
),
|
||||||
selectedIcon: Badge(
|
NavigationDestination(
|
||||||
isLabelVisible: queueState.queuedCount > 0,
|
icon: Badge(
|
||||||
label: Text('${queueState.queuedCount}'),
|
isLabelVisible: queueState > 0,
|
||||||
child: const Icon(Icons.download),
|
label: Text('$queueState'),
|
||||||
|
child: const Icon(Icons.history_outlined),
|
||||||
|
),
|
||||||
|
selectedIcon: Badge(
|
||||||
|
isLabelVisible: queueState > 0,
|
||||||
|
label: Text('$queueState'),
|
||||||
|
child: const Icon(Icons.history),
|
||||||
|
),
|
||||||
|
label: 'History',
|
||||||
),
|
),
|
||||||
label: 'Downloads',
|
const NavigationDestination(
|
||||||
),
|
icon: Icon(Icons.settings_outlined),
|
||||||
const NavigationDestination(
|
selectedIcon: Icon(Icons.settings),
|
||||||
icon: Icon(Icons.settings_outlined),
|
label: 'Settings',
|
||||||
selectedIcon: Icon(Icons.settings),
|
),
|
||||||
label: 'Settings',
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
|
||||||
|
/// Playlist detail screen with Material Expressive 3 design
|
||||||
|
class PlaylistScreen extends ConsumerWidget {
|
||||||
|
final String playlistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
final List<Track> tracks;
|
||||||
|
|
||||||
|
const PlaylistScreen({
|
||||||
|
super.key,
|
||||||
|
required this.playlistName,
|
||||||
|
this.coverUrl,
|
||||||
|
required this.tracks,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildAppBar(context, colorScheme),
|
||||||
|
_buildInfoCard(context, ref, colorScheme),
|
||||||
|
_buildTrackListHeader(context, colorScheme),
|
||||||
|
_buildTrackList(context, ref, colorScheme),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: 280,
|
||||||
|
pinned: true,
|
||||||
|
stretch: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (coverUrl != null)
|
||||||
|
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: coverUrl != null
|
||||||
|
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||||
|
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _downloadAll(context, ref),
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: Text('Download All (${tracks.length})'),
|
||||||
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _PlaylistTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, ref, track),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, ref, (quality, service) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadAll(BuildContext context, WidgetRef ref) {
|
||||||
|
if (tracks.isEmpty) return;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (settings.askQualityBeforeDownload) {
|
||||||
|
_showQualityPicker(context, ref, (quality, service) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}, trackName: '${tracks.length} tracks', artistName: playlistName);
|
||||||
|
} else {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
String selectedService = settings.defaultService;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setModalState) => SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (trackName != null) ...[
|
||||||
|
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
|
||||||
|
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
|
||||||
|
],
|
||||||
|
// Service selector
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||||
|
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))),
|
||||||
|
// Disclaimer
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
|
||||||
|
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QualityOption extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
|
leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)),
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServiceChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeader extends StatefulWidget {
|
||||||
|
final String trackName;
|
||||||
|
final String? artistName;
|
||||||
|
final String? coverUrl;
|
||||||
|
const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_TrackInfoHeader> createState() => _TrackInfoHeaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TrackInfoHeaderState extends State<_TrackInfoHeader> {
|
||||||
|
bool _expanded = false;
|
||||||
|
bool _isOverflowing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null,
|
||||||
|
borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)))
|
||||||
|
: Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||||
|
final titleSpan = TextSpan(text: widget.trackName, style: titleStyle);
|
||||||
|
final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth);
|
||||||
|
final titleOverflows = titlePainter.didExceedMaxLines;
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _isOverflowing != titleOverflows) {
|
||||||
|
setState(() => _isOverflowing = titleOverflows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.trackName,
|
||||||
|
style: titleStyle,
|
||||||
|
maxLines: _expanded ? 10 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (widget.artistName != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.artistName!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
maxLines: _expanded ? 3 : 1,
|
||||||
|
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isOverflowing || _expanded)
|
||||||
|
Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
class _PlaylistTrackItem extends ConsumerWidget {
|
||||||
|
final Track track;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
|
||||||
|
const _PlaylistTrackItem({required this.track, required this.onDownload});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Only watch the specific item for this track
|
||||||
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
|
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if track is in history (already downloaded before)
|
||||||
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
|
return state.isDownloaded(track.id);
|
||||||
|
}));
|
||||||
|
|
||||||
|
final isQueued = queueItem != null;
|
||||||
|
final isDownloading = queueItem?.status == DownloadStatus.downloading;
|
||||||
|
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
|
||||||
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
|
// Show as downloaded if in queue completed OR in history
|
||||||
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: Colors.transparent,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
leading: track.coverUrl != null
|
||||||
|
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96))
|
||||||
|
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
|
||||||
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
|
||||||
|
subtitle: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
|
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, progress: progress),
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory}) async {
|
||||||
|
if (isQueued) return;
|
||||||
|
|
||||||
|
if (isInHistory) {
|
||||||
|
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||||
|
if (historyItem != null) {
|
||||||
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
|
if (fileExists) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
|
||||||
|
required bool isQueued,
|
||||||
|
required bool isDownloading,
|
||||||
|
required bool isFinalizing,
|
||||||
|
required bool showAsDownloaded,
|
||||||
|
required bool isInHistory,
|
||||||
|
required double progress,
|
||||||
|
}) {
|
||||||
|
const double size = 44.0;
|
||||||
|
const double iconSize = 20.0;
|
||||||
|
|
||||||
|
if (showAsDownloaded) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory),
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
} else if (isFinalizing) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isDownloading) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
|
||||||
|
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isQueued) {
|
||||||
|
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
|
||||||
|
} else {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onDownload,
|
||||||
|
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||