Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74bc747599 | |||
| cbc8fdcb0c | |||
| 3b79b4f1ca | |||
| 5692a76650 | |||
| 7a009ad0af | |||
| e5e75e7092 | |||
| 01b8fd2480 | |||
| ee807a44cc | |||
| c9b905eb18 |
@@ -3,13 +3,13 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: 'Version tag (e.g., v1.0.0)'
|
description: "Version tag (e.g., v1.0.0)"
|
||||||
required: true
|
required: true
|
||||||
default: 'v1.0.0'
|
default: "v1.0.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Get version first (quick job)
|
# Get version first (quick job)
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
fi
|
fi
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||||
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||||
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
build-android:
|
build-android:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: get-version
|
needs: get-version
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Free disk space
|
- name: Free disk space
|
||||||
run: |
|
run: |
|
||||||
@@ -65,13 +65,13 @@ jobs:
|
|||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: "temurin"
|
||||||
java-version: '17'
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: "1.21"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
@@ -89,13 +89,13 @@ jobs:
|
|||||||
# Use pre-installed Android SDK on GitHub runners
|
# Use pre-installed Android SDK on GitHub runners
|
||||||
echo "ANDROID_HOME=$ANDROID_HOME"
|
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||||
|
|
||||||
# Accept licenses
|
# Accept licenses
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||||
|
|
||||||
# Install NDK (required for gomobile)
|
# Install NDK (required for gomobile)
|
||||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;25.2.9519653" "platforms;android-34" "build-tools;34.0.0"
|
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;25.2.9519653" "platforms;android-34" "build-tools;34.0.0"
|
||||||
|
|
||||||
# Set NDK path
|
# Set NDK path
|
||||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.2.9519653" >> $GITHUB_ENV
|
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.2.9519653" >> $GITHUB_ENV
|
||||||
|
|
||||||
@@ -115,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
|
||||||
@@ -125,7 +125,14 @@ jobs:
|
|||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
- name: Build APK (Release - unsigned)
|
- name: Build APK (Release - unsigned)
|
||||||
run: flutter build apk --release --split-per-abi
|
run: |
|
||||||
|
flutter build apk --release --split-per-abi || true
|
||||||
|
# Verify APKs were created
|
||||||
|
ls -la build/app/outputs/flutter-apk/
|
||||||
|
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
||||||
|
echo "ERROR: APK not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Sign APKs
|
- name: Sign APKs
|
||||||
uses: r0adkll/sign-android-release@v1
|
uses: r0adkll/sign-android-release@v1
|
||||||
@@ -157,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
|
||||||
@@ -166,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
|
||||||
@@ -194,51 +201,51 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ls -la ios/Frameworks/
|
ls -la ios/Frameworks/
|
||||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||||
|
|
||||||
- name: Add XCFramework to Xcode project
|
- name: Add XCFramework to Xcode project
|
||||||
run: |
|
run: |
|
||||||
# Install xcodeproj gem for modifying Xcode project
|
# Install xcodeproj gem for modifying Xcode project
|
||||||
sudo gem install xcodeproj
|
sudo gem install xcodeproj
|
||||||
|
|
||||||
# Create Ruby script to add framework
|
# Create Ruby script to add framework
|
||||||
cat > add_framework.rb << 'EOF'
|
cat > add_framework.rb << 'EOF'
|
||||||
require 'xcodeproj'
|
require 'xcodeproj'
|
||||||
|
|
||||||
project_path = 'ios/Runner.xcodeproj'
|
project_path = 'ios/Runner.xcodeproj'
|
||||||
project = Xcodeproj::Project.open(project_path)
|
project = Xcodeproj::Project.open(project_path)
|
||||||
|
|
||||||
# Get the main target
|
# Get the main target
|
||||||
target = project.targets.find { |t| t.name == 'Runner' }
|
target = project.targets.find { |t| t.name == 'Runner' }
|
||||||
|
|
||||||
# Get or create Frameworks group
|
# Get or create Frameworks group
|
||||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||||
|
|
||||||
# Add XCFramework reference
|
# Add XCFramework reference
|
||||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||||
|
|
||||||
# Add to frameworks build phase
|
# Add to frameworks build phase
|
||||||
frameworks_build_phase = target.frameworks_build_phase
|
frameworks_build_phase = target.frameworks_build_phase
|
||||||
frameworks_build_phase.add_file_reference(framework_ref)
|
frameworks_build_phase.add_file_reference(framework_ref)
|
||||||
|
|
||||||
# Add to embed frameworks build phase
|
# Add to embed frameworks build phase
|
||||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||||
if embed_phase
|
if embed_phase
|
||||||
build_file = embed_phase.add_file_reference(framework_ref)
|
build_file = embed_phase.add_file_reference(framework_ref)
|
||||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||||
end
|
end
|
||||||
|
|
||||||
project.save
|
project.save
|
||||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
ruby add_framework.rb
|
ruby add_framework.rb
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
||||||
@@ -265,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:
|
||||||
@@ -288,7 +321,7 @@ jobs:
|
|||||||
needs: [get-version, build-android, build-ios]
|
needs: [get-version, build-android, build-ios]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -298,13 +331,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||||
|
|
||||||
echo "Looking for version: $VERSION_NUM"
|
echo "Looking for version: $VERSION_NUM"
|
||||||
|
|
||||||
# Extract changelog section for this version using sed
|
# Extract changelog section for this version using sed
|
||||||
# Find the line with version, then print until next version header or end
|
# Find the line with version, then print until next version header or end
|
||||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||||
|
|
||||||
# If no changelog found, use default message
|
# If no changelog found, use default message
|
||||||
if [ -z "$CHANGELOG" ]; then
|
if [ -z "$CHANGELOG" ]; then
|
||||||
echo "No changelog found for version $VERSION_NUM"
|
echo "No changelog found for version $VERSION_NUM"
|
||||||
@@ -312,7 +345,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "Found changelog content"
|
echo "Found changelog content"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Save to file for multiline support
|
# Save to file for multiline support
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
echo "Extracted changelog:"
|
echo "Extracted changelog:"
|
||||||
@@ -334,32 +367,37 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
cat > /tmp/release_body.txt << 'HEADER'
|
cat > /tmp/release_body.txt << 'HEADER'
|
||||||
## SpotiFLAC $VERSION
|
|
||||||
|
|
||||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
|
||||||
|
|
||||||
### What's New
|
### What's New
|
||||||
HEADER
|
HEADER
|
||||||
|
|
||||||
# Replace $VERSION in header
|
|
||||||
sed -i "s/\$VERSION/$VERSION/g" /tmp/release_body.txt
|
|
||||||
|
|
||||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||||
|
|
||||||
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
|
REPO_NAME="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
cat >> /tmp/release_body.txt << FOOTER
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Downloads
|
### Downloads
|
||||||
- **Android (arm64)**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended)
|
|
||||||
- **Android (arm32)**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
#### Android
|
||||||
|

|
||||||
|

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

|
||||||
|
|
||||||
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
cat /tmp/release_body.txt
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,115 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [2.1.5] - 2026-01-08
|
## [2.1.7] - 2026-01-09
|
||||||
|
|
||||||
### Added
|
### 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
|
- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search
|
||||||
|
|
||||||
- Configure in Settings > Options > Spotify API > Search Source
|
- Configure in Settings > Options > Spotify API > Search Source
|
||||||
- Default is Deezer for better reliability
|
- Default is Deezer for better reliability
|
||||||
- Spotify URLs are always supported regardless of this setting
|
- 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
|
- **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
|
- Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID
|
||||||
- Fetches metadata from Deezer instead
|
- Fetches metadata from Deezer instead
|
||||||
- Works for tracks and albums (playlists are user-specific, artists require Spotify API)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Default Download Service**: Changed from Tidal to Qobuz
|
- **Default Download Service**: Changed from Tidal to Qobuz
|
||||||
- Fallback order is now: Qobuz → Tidal → Amazon
|
- Fallback order is now: Qobuz → Tidal → Amazon
|
||||||
- **Deezer API Updated to v2.0**: More reliable and complete metadata
|
- **Deezer API Updated to v2.0**: More reliable and complete metadata
|
||||||
@@ -20,6 +117,7 @@
|
|||||||
- Search results now fetch full track info to include ISRC
|
- Search results now fetch full track info to include ISRC
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
|
- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
|
||||||
- Progress now updates smoothly every 64KB of data received
|
- Progress now updates smoothly every 64KB of data received
|
||||||
- First progress update happens immediately when download starts
|
- First progress update happens immediately when download starts
|
||||||
@@ -28,18 +126,17 @@
|
|||||||
- Incomplete files are automatically deleted and error is reported
|
- Incomplete files are automatically deleted and error is reported
|
||||||
- Applies to all services: Tidal, Qobuz, and Amazon
|
- Applies to all services: Tidal, Qobuz, and Amazon
|
||||||
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
|
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
|
||||||
- Improves track matching accuracy when downloading
|
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
- New settings field: `metadataSource` in `lib/models/settings.dart`
|
|
||||||
- New UI: Search Source selector in Options Settings page
|
|
||||||
- Improved `ItemProgressWriter` with threshold-based progress updates
|
|
||||||
- Download functions now properly handle network interruptions
|
|
||||||
- Deezer API base URL changed to `https://api.deezer.com/2.0`
|
|
||||||
|
|
||||||
## [2.1.0] - 2026-01-06
|
- Settings migration for existing users to set Deezer as default metadata source
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.5] - 2026-01-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
||||||
- Service selector chips appear above quality options
|
- Service selector chips appear above quality options
|
||||||
- Defaults to your preferred service from settings
|
- Defaults to your preferred service from settings
|
||||||
@@ -55,6 +152,7 @@
|
|||||||
- Configure in Settings > Options > App
|
- Configure in Settings > Options > App
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
||||||
- arm64 APK: 46.6 MB (previously 51 MB)
|
- arm64 APK: 46.6 MB (previously 51 MB)
|
||||||
- arm32 APK: 59 MB (previously 64 MB)
|
- arm32 APK: 59 MB (previously 64 MB)
|
||||||
@@ -64,6 +162,7 @@
|
|||||||
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
||||||
- Now properly handles retry when queue processing has finished
|
- Now properly handles retry when queue processing has finished
|
||||||
- Also allows retrying skipped (cancelled) downloads
|
- Also allows retrying skipped (cancelled) downloads
|
||||||
@@ -75,6 +174,7 @@
|
|||||||
- Files saved to app Documents folder are accessible via iOS Files app
|
- Files saved to app Documents folder are accessible via iOS Files app
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
||||||
- Token caching for Tidal (eliminates redundant auth requests)
|
- Token caching for Tidal (eliminates redundant auth requests)
|
||||||
- Singleton pattern for all downloaders (HTTP connection reuse)
|
- Singleton pattern for all downloaders (HTTP connection reuse)
|
||||||
@@ -90,6 +190,7 @@
|
|||||||
## [2.1.0-preview2] - 2026-01-06
|
## [2.1.0-preview2] - 2026-01-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
|
||||||
- Service selector chips appear above quality options
|
- Service selector chips appear above quality options
|
||||||
- Defaults to your preferred service from settings
|
- Defaults to your preferred service from settings
|
||||||
@@ -105,6 +206,7 @@
|
|||||||
- Configure in Settings > Options > App
|
- Configure in Settings > Options > App
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
|
||||||
- Now properly handles retry when queue processing has finished
|
- Now properly handles retry when queue processing has finished
|
||||||
- Also allows retrying skipped (cancelled) downloads
|
- Also allows retrying skipped (cancelled) downloads
|
||||||
@@ -116,6 +218,7 @@
|
|||||||
## [2.1.0-preview] - 2026-01-06
|
## [2.1.0-preview] - 2026-01-06
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
|
||||||
- Token caching for Tidal (eliminates redundant auth requests)
|
- Token caching for Tidal (eliminates redundant auth requests)
|
||||||
- Singleton pattern for all downloaders (HTTP connection reuse)
|
- Singleton pattern for all downloaders (HTTP connection reuse)
|
||||||
@@ -129,6 +232,7 @@
|
|||||||
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
|
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
|
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
|
||||||
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
|
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
|
||||||
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
|
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
|
||||||
@@ -136,6 +240,7 @@
|
|||||||
## [2.0.7-preview2] - 2026-01-06
|
## [2.0.7-preview2] - 2026-01-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
|
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
|
||||||
- iOS limitation: Empty folders cannot be selected via document picker
|
- iOS limitation: Empty folders cannot be selected via document picker
|
||||||
- Added "App Documents Folder" option as recommended default
|
- Added "App Documents Folder" option as recommended default
|
||||||
@@ -145,6 +250,7 @@
|
|||||||
## [2.0.7-preview] - 2026-01-05
|
## [2.0.7-preview] - 2026-01-05
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
|
||||||
- arm64 APK: 46.6 MB (previously 51 MB)
|
- arm64 APK: 46.6 MB (previously 51 MB)
|
||||||
- arm32 APK: 59 MB (previously 64 MB)
|
- arm32 APK: 59 MB (previously 64 MB)
|
||||||
@@ -152,6 +258,7 @@
|
|||||||
- Removed x86/x86_64 architectures (emulator only)
|
- Removed x86/x86_64 architectures (emulator only)
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
|
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
|
||||||
- Native MethodChannel bridge for FFmpeg operations
|
- Native MethodChannel bridge for FFmpeg operations
|
||||||
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
|
||||||
@@ -159,6 +266,7 @@
|
|||||||
## [2.0.6] - 2026-01-05
|
## [2.0.6] - 2026-01-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
|
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
|
||||||
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
|
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
|
||||||
- Now properly converts milliseconds to seconds before display
|
- Now properly converts milliseconds to seconds before display
|
||||||
@@ -178,14 +286,17 @@
|
|||||||
## [2.0.5] - 2026-01-05
|
## [2.0.5] - 2026-01-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
|
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
|
||||||
|
|
||||||
## [2.0.4] - 2026-01-04
|
## [2.0.4] - 2026-01-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
|
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
|
||||||
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
|
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
|
||||||
- Shows explanation dialog before opening system settings
|
- Shows explanation dialog before opening system settings
|
||||||
@@ -193,6 +304,7 @@
|
|||||||
## [2.0.3] - 2026-01-03
|
## [2.0.3] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
|
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
|
||||||
- Toggle to enable/disable custom credentials without deleting them
|
- Toggle to enable/disable custom credentials without deleting them
|
||||||
- Material Expressive 3 bottom sheet UI for entering credentials
|
- Material Expressive 3 bottom sheet UI for entering credentials
|
||||||
@@ -200,9 +312,11 @@
|
|||||||
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
|
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
|
||||||
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
|
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
|
||||||
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
|
||||||
@@ -212,6 +326,7 @@
|
|||||||
## [2.0.2] - 2026-01-03
|
## [2.0.2] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
|
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
|
||||||
- Quality badge on download history items (e.g., "24-bit", "16-bit")
|
- Quality badge on download history items (e.g., "24-bit", "16-bit")
|
||||||
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
|
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
|
||||||
@@ -220,13 +335,16 @@
|
|||||||
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
|
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
|
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
|
||||||
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
|
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
|
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
|
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
|
||||||
- Go backend now returns `service` field indicating actual service used (important for fallback)
|
- Go backend now returns `service` field indicating actual service used (important for fallback)
|
||||||
- Tidal API v2 response provides exact quality info
|
- Tidal API v2 response provides exact quality info
|
||||||
@@ -236,18 +354,21 @@
|
|||||||
## [2.0.1] - 2026-01-03
|
## [2.0.1] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
|
||||||
- Tap to expand long track titles
|
- Tap to expand long track titles
|
||||||
- Expand icon only shows when title is truncated
|
- Expand icon only shows when title is truncated
|
||||||
- Ripple effect follows rounded corners including drag handle
|
- Ripple effect follows rounded corners including drag handle
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
|
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
|
||||||
- All downloads now use item-based progress tracking
|
- All downloads now use item-based progress tracking
|
||||||
- Fixes duplicate notification bug when finalizing
|
- Fixes duplicate notification bug when finalizing
|
||||||
- Cleaner codebase with single progress system
|
- Cleaner codebase with single progress system
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
|
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
|
||||||
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
|
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
|
||||||
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
|
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
|
||||||
@@ -257,6 +378,7 @@
|
|||||||
## [2.0.0] - 2026-01-03
|
## [2.0.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Artist Search Results**: Search now shows artists alongside tracks
|
- **Artist Search Results**: Search now shows artists alongside tracks
|
||||||
- Horizontal scrollable artist cards with circular avatars
|
- Horizontal scrollable artist cards with circular avatars
|
||||||
- Tap artist to view their discography
|
- Tap artist to view their discography
|
||||||
@@ -277,11 +399,12 @@
|
|||||||
- Stable users won't receive update notifications for preview versions
|
- Stable users won't receive update notifications for preview versions
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
|
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
|
||||||
- Header (name, cover) shows instantly from available data
|
- Header (name, cover) shows instantly from available data
|
||||||
- Content (albums/tracks) loads in background inside the screen
|
- Content (albums/tracks) loads in background inside the screen
|
||||||
- Second visit to same artist/album is instant from Flutter cache
|
- Second visit to same artist/album is instant from Flutter cache
|
||||||
- **Search Results UI Redesign**:
|
- **Search Results UI Redesign**:
|
||||||
- Removed "Download All" button from search results
|
- Removed "Download All" button from search results
|
||||||
- Added "Songs" section header (matches "Artists" header style)
|
- Added "Songs" section header (matches "Artists" header style)
|
||||||
- Track list now in grouped card with rounded corners (like Settings)
|
- Track list now in grouped card with rounded corners (like Settings)
|
||||||
@@ -304,6 +427,7 @@
|
|||||||
- **Ask Before Download Default**: Now enabled by default for better UX
|
- **Ask Before Download Default**: Now enabled by default for better UX
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
||||||
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
|
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
|
||||||
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
|
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
|
||||||
@@ -314,6 +438,7 @@
|
|||||||
## [1.6.3] - 2026-01-03
|
## [1.6.3] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
|
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
|
||||||
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
|
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
|
||||||
- Collapsing header with cover art and gradient overlay
|
- Collapsing header with cover art and gradient overlay
|
||||||
@@ -323,6 +448,7 @@
|
|||||||
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
|
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
|
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
|
||||||
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
|
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
|
||||||
- Enables native predictive back gesture animations
|
- Enables native predictive back gesture animations
|
||||||
@@ -332,17 +458,21 @@
|
|||||||
## [1.6.2] - 2026-01-02
|
## [1.6.2] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
|
||||||
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
|
||||||
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
|
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
|
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
|
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
|
||||||
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
|
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
|
||||||
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
|
||||||
@@ -354,12 +484,14 @@
|
|||||||
## [1.6.1] - 2026-01-02
|
## [1.6.1] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Background Download Service**: Downloads now continue running when app is in background
|
- **Background Download Service**: Downloads now continue running when app is in background
|
||||||
- Foreground service with wake lock prevents Android from killing downloads
|
- Foreground service with wake lock prevents Android from killing downloads
|
||||||
- Persistent notification shows download progress
|
- Persistent notification shows download progress
|
||||||
- No more "connection abort" errors when switching apps
|
- No more "connection abort" errors when switching apps
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
|
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
|
||||||
- Download queue is now persisted to storage and automatically restored on app restart
|
- Download queue is now persisted to storage and automatically restored on app restart
|
||||||
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
|
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
|
||||||
@@ -368,11 +500,13 @@
|
|||||||
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
|
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
|
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
|
||||||
|
|
||||||
## [1.6.0] - 2026-01-02
|
## [1.6.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Manual Quality Selection**: New option to choose audio quality before each download
|
- **Manual Quality Selection**: New option to choose audio quality before each download
|
||||||
- Toggle "Ask Before Download" in Download Settings
|
- Toggle "Ask Before Download" in Download Settings
|
||||||
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
|
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
|
||||||
@@ -387,12 +521,14 @@
|
|||||||
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
|
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
|
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
|
||||||
- Users on hotfix versions now properly receive update notifications
|
- Users on hotfix versions now properly receive update notifications
|
||||||
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
|
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
|
||||||
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
|
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
|
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
|
||||||
- Items in same group are connected with rounded card container
|
- Items in same group are connected with rounded card container
|
||||||
- Section headers outside cards for clear visual hierarchy
|
- Section headers outside cards for clear visual hierarchy
|
||||||
@@ -401,6 +537,7 @@
|
|||||||
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
|
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
|
||||||
- **Dependencies Updated**:
|
- **Dependencies Updated**:
|
||||||
- `share_plus`: 10.1.4 → 12.0.1
|
- `share_plus`: 10.1.4 → 12.0.1
|
||||||
@@ -410,6 +547,7 @@
|
|||||||
## [1.5.5] - 2026-01-02
|
## [1.5.5] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
|
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
|
||||||
- Supports track, album, playlist, and artist URLs
|
- Supports track, album, playlist, and artist URLs
|
||||||
- Auto-fetches metadata when link is shared
|
- Auto-fetches metadata when link is shared
|
||||||
@@ -436,6 +574,7 @@
|
|||||||
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
|
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
|
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
|
||||||
- Shows download queue at top when active
|
- Shows download queue at top when active
|
||||||
- Completed downloads auto-move to history section
|
- Completed downloads auto-move to history section
|
||||||
@@ -446,11 +585,13 @@
|
|||||||
- Only shows exit dialog when truly at root
|
- Only shows exit dialog when truly at root
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added)
|
- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added)
|
||||||
- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views
|
- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views
|
||||||
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
|
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search
|
- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search
|
||||||
- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab
|
- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab
|
||||||
- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones
|
- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones
|
||||||
@@ -459,26 +600,31 @@
|
|||||||
## [1.5.0-hotfix6] - 2026-01-02
|
## [1.5.0-hotfix6] - 2026-01-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
|
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
|
||||||
|
|
||||||
## [1.5.0-hotfix5] - 2026-01-02
|
## [1.5.0-hotfix5] - 2026-01-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **App Signing**: Use key.properties as per Flutter official documentation
|
- **App Signing**: Use key.properties as per Flutter official documentation
|
||||||
|
|
||||||
## [1.5.0-hotfix4] - 2026-01-02
|
## [1.5.0-hotfix4] - 2026-01-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **App Signing**: Create keystore.properties in workflow for Gradle
|
- **App Signing**: Create keystore.properties in workflow for Gradle
|
||||||
|
|
||||||
## [1.5.0-hotfix] - 2026-01-02
|
## [1.5.0-hotfix] - 2026-01-02
|
||||||
|
|
||||||
### Important Notice
|
### Important Notice
|
||||||
|
|
||||||
We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
|
We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
|
||||||
|
|
||||||
**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
|
**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **In-App Update**: Download and install updates directly from the app
|
- **In-App Update**: Download and install updates directly from the app
|
||||||
- Progress bar shows download status
|
- Progress bar shows download status
|
||||||
- Automatic device architecture detection (arm64/arm32)
|
- Automatic device architecture detection (arm64/arm32)
|
||||||
@@ -486,11 +632,13 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- **Consistent App Signing**: All future releases will use the same signing key
|
- **Consistent App Signing**: All future releases will use the same signing key
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Update Checker**: Now downloads APK directly instead of opening browser
|
- **Update Checker**: Now downloads APK directly instead of opening browser
|
||||||
|
|
||||||
## [1.5.0] - 2026-01-02
|
## [1.5.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
|
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
|
||||||
- Progress bar in notification during download
|
- Progress bar in notification during download
|
||||||
- Completion notification when track finishes
|
- Completion notification when track finishes
|
||||||
@@ -514,6 +662,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- Downloads correct APK for your device
|
- Downloads correct APK for your device
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
|
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
|
||||||
- **Queue UI Redesign**: Card-based layout with clearer status indicators
|
- **Queue UI Redesign**: Card-based layout with clearer status indicators
|
||||||
- Removed global pause/resume in favor of per-item controls
|
- Removed global pause/resume in favor of per-item controls
|
||||||
@@ -538,6 +687,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- "Add Music" button for quick access
|
- "Add Music" button for quick access
|
||||||
|
|
||||||
### Technical
|
### Technical
|
||||||
|
|
||||||
- Added `flutter_local_notifications` package for notifications
|
- Added `flutter_local_notifications` package for notifications
|
||||||
- Added notification permission request in setup screen for Android 13+
|
- Added notification permission request in setup screen for Android 13+
|
||||||
- Enabled core library desugaring for all Android subprojects
|
- Enabled core library desugaring for all Android subprojects
|
||||||
@@ -546,6 +696,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
|
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
|
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
|
||||||
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
|
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
|
||||||
- Optimized state management: Use `select()` to only rebuild when specific state changes
|
- Optimized state management: Use `select()` to only rebuild when specific state changes
|
||||||
@@ -554,6 +705,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
## [1.2.0] - 2026-01-02
|
## [1.2.0] - 2026-01-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
|
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
|
||||||
- Material Expressive 3 design with cover art header and gradient
|
- Material Expressive 3 design with cover art header and gradient
|
||||||
- Hero animation from list to detail view
|
- Hero animation from list to detail view
|
||||||
@@ -566,12 +718,14 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
|
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
|
- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
|
||||||
- Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
|
- Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
|
||||||
- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
|
- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
|
||||||
- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
|
- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
|
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
|
||||||
- Play button still available for quick playback
|
- Play button still available for quick playback
|
||||||
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
|
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
|
||||||
@@ -580,30 +734,34 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
## [1.1.2] - 2026-01-01
|
## [1.1.2] - 2026-01-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Update Checker**: Automatic check for new versions from GitHub releases
|
- **Update Checker**: Automatic check for new versions from GitHub releases
|
||||||
- Shows changelog in update dialog
|
- Shows changelog in update dialog
|
||||||
- Option to disable update notifications
|
- Option to disable update notifications
|
||||||
- **Release Changelog**: GitHub releases now include full changelog
|
- **Release Changelog**: GitHub releases now include full changelog
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Updated version to 1.1.2
|
- Updated version to 1.1.2
|
||||||
|
|
||||||
## [1.1.1] - 2026-01-01
|
## [1.1.1] - 2026-01-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **About Dialog**: Custom About dialog with cleaner layout
|
- **About Dialog**: Custom About dialog with cleaner layout
|
||||||
- **Setup Screen**: Fixed step indicator line alignment
|
- **Setup Screen**: Fixed step indicator line alignment
|
||||||
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
|
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
|
||||||
- **Copyright Year**: Updated to 2026
|
- **Copyright Year**: Updated to 2026
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Removed Theme Preview from Settings
|
- Removed Theme Preview from Settings
|
||||||
- Added MIT License
|
- Added MIT License
|
||||||
|
|
||||||
|
|
||||||
## [1.1.0] - 2026-01-01
|
## [1.1.0] - 2026-01-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
|
||||||
- Default: Sequential (1 at a time) for stability
|
- Default: Sequential (1 at a time) for stability
|
||||||
- Options: 1, 2, or 3 concurrent downloads
|
- Options: 1, 2, or 3 concurrent downloads
|
||||||
@@ -614,15 +772,18 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
|
||||||
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
|
||||||
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
|
||||||
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Updated version to 1.1.0
|
- Updated version to 1.1.0
|
||||||
|
|
||||||
### Technical Details
|
### Technical Details
|
||||||
|
|
||||||
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
|
||||||
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
|
||||||
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
|
||||||
@@ -631,6 +792,7 @@ We apologize for the inconvenience. Previous releases were signed with different
|
|||||||
- Added `CleanupConnections()` export for Flutter to call via method channel
|
- Added `CleanupConnections()` export for Flutter to call via method channel
|
||||||
|
|
||||||
## [1.0.5] - Previous Release
|
## [1.0.5] - Previous Release
|
||||||
|
|
||||||
- Material Expressive 3 UI
|
- Material Expressive 3 UI
|
||||||
- Dynamic color support
|
- Dynamic color support
|
||||||
- Swipe navigation with PageView
|
- Swipe navigation with PageView
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/9092dd9300289ceadd8e70cd71706a3ba32225d9cb2ae8b12648611d31814708)
|
[](https://www.virustotal.com/gui/file/ca16289599f71b8e50d3726a8c64a202ea922a1893bcf21b9eca1a050736f1f5/)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ SpotiFLAC supports two metadata sources for searching tracks:
|
|||||||
To use Spotify as your search source without hitting rate limits:
|
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)
|
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
|
2. Create an app to get your Client ID and Client Secret
|
||||||
3. Go to **Settings > Options > Spotify API > Custom Credentials**
|
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
|
||||||
4. Enter your Client ID and Secret
|
4. Enter your Client ID and Secret
|
||||||
5. Change **Search Source** to Spotify
|
5. Change **Search Source** to Spotify
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -7,9 +10,9 @@ plugins {
|
|||||||
|
|
||||||
// Load keystore properties for local builds
|
// Load keystore properties for local builds
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
val keystoreProperties = java.util.Properties()
|
val keystoreProperties = Properties()
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -32,10 +35,10 @@ android {
|
|||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
create("release") {
|
create("release") {
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
storePassword = keystoreProperties["storePassword"] as String
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,8 +97,10 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
implementation(files("libs/gobackend.aar"))
|
|
||||||
implementation(files("libs/ffmpeg-kit-with-lame.aar"))
|
// Include all AAR and JAR files from libs folder
|
||||||
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-keep class io.flutter.plugins.** { *; }
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.**
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.**
|
||||||
|
-dontwarn com.google.android.play.core.tasks.**
|
||||||
|
|
||||||
|
# Ignore missing javax.xml.stream (not used on Android)
|
||||||
|
-dontwarn javax.xml.stream.**
|
||||||
|
|
||||||
# Go backend (gobackend.aar)
|
# Go backend (gobackend.aar)
|
||||||
-keep class gobackend.** { *; }
|
-keep class gobackend.** { *; }
|
||||||
-keep class go.** { *; }
|
-keep class go.** { *; }
|
||||||
@@ -14,6 +22,9 @@
|
|||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-keep class com.arthenica.smartexception.** { *; }
|
||||||
|
|
||||||
|
# Apache Tika (if used by FFmpeg)
|
||||||
|
-dontwarn org.apache.tika.**
|
||||||
|
|
||||||
# Keep native methods
|
# Keep native methods
|
||||||
-keepclasseswithmembernames class * {
|
-keepclasseswithmembernames class * {
|
||||||
native <methods>;
|
native <methods>;
|
||||||
|
|||||||
|
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 |
@@ -105,6 +105,78 @@ class FFmpegServiceIOS {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embed metadata and cover art to FLAC file
|
||||||
|
/// Returns the file path on success, null on failure
|
||||||
|
static Future<String?> embedMetadata({
|
||||||
|
required String flacPath,
|
||||||
|
String? coverPath,
|
||||||
|
Map<String, String>? metadata,
|
||||||
|
}) async {
|
||||||
|
final tempOutput = '$flacPath.tmp';
|
||||||
|
|
||||||
|
// Construct command
|
||||||
|
final StringBuffer cmdBuffer = StringBuffer();
|
||||||
|
cmdBuffer.write('-i "$flacPath" ');
|
||||||
|
|
||||||
|
// Add cover input if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-i "$coverPath" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map audio stream
|
||||||
|
cmdBuffer.write('-map 0:a ');
|
||||||
|
|
||||||
|
// Map cover stream if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-map 1:0 ');
|
||||||
|
cmdBuffer.write('-c:v copy ');
|
||||||
|
cmdBuffer.write('-disposition:v attached_pic ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy audio codec (don't re-encode)
|
||||||
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
|
// Add text metadata
|
||||||
|
if (metadata != null) {
|
||||||
|
metadata.forEach((key, value) {
|
||||||
|
// Sanitize value: escape double quotes
|
||||||
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
|
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('"$tempOutput" -y');
|
||||||
|
|
||||||
|
final command = cmdBuffer.toString();
|
||||||
|
_log.d('Executing FFmpeg command: $command');
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(flacPath).delete();
|
||||||
|
await File(tempOutput).rename(flacPath);
|
||||||
|
return flacPath;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace file after metadata embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file if exists
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
/// Check if FFmpeg is available
|
||||||
static Future<bool> isAvailable() async {
|
static Future<bool> isAvailable() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ import (
|
|||||||
|
|
||||||
// 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 (
|
var (
|
||||||
// Global Amazon downloader instance for connection reuse
|
// Global Amazon downloader instance for connection reuse
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
amazonDownloaderOnce sync.Once
|
||||||
|
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
|
||||||
)
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||||
@@ -105,13 +109,55 @@ func amazonIsASCIIString(s string) bool {
|
|||||||
func NewAmazonDownloader() *AmazonDownloader {
|
func NewAmazonDownloader() *AmazonDownloader {
|
||||||
amazonDownloaderOnce.Do(func() {
|
amazonDownloaderOnce.Do(func() {
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
globalAmazonDownloader = &AmazonDownloader{
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
regions: []string{"us", "eu"}, // Same regions as PC
|
||||||
|
apiCallResetTime: time.Now(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalAmazonDownloader
|
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
|
||||||
// Uses same service as PC version (doubledouble.top)
|
// Uses same service as PC version (doubledouble.top)
|
||||||
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
||||||
@@ -140,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)
|
||||||
@@ -153,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,9 +425,15 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
|
|
||||||
// AmazonDownloadResult contains download result with quality info
|
// AmazonDownloadResult contains download result with quality info
|
||||||
type AmazonDownloadResult struct {
|
type AmazonDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromAmazon downloads a track using the request parameters
|
// downloadFromAmazon downloads a track using the request parameters
|
||||||
@@ -365,7 +448,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
// Get Amazon URL from SongLink
|
// Get Amazon URL from SongLink
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
var availability *TrackAvailability
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||||
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
|
// Extract Deezer ID and use Deezer-based lookup
|
||||||
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
|
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 AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||||
}
|
}
|
||||||
@@ -491,6 +589,8 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
quality, err := GetAudioQuality(outputPath)
|
quality, err := GetAudioQuality(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
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 0 to indicate unknown quality
|
||||||
return AmazonDownloadResult{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
@@ -500,9 +600,19 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
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{
|
return AmazonDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: quality.BitDepth,
|
BitDepth: quality.BitDepth,
|
||||||
SampleRate: quality.SampleRate,
|
SampleRate: quality.SampleRate,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: req.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ const (
|
|||||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||||
|
|
||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// Parallel ISRC fetching settings
|
||||||
|
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeezerClient handles Deezer API interactions (no auth required)
|
// DeezerClient handles Deezer API interactions (no auth required)
|
||||||
@@ -29,6 +32,7 @@ type DeezerClient struct {
|
|||||||
searchCache map[string]*cacheEntry
|
searchCache map[string]*cacheEntry
|
||||||
albumCache map[string]*cacheEntry
|
albumCache map[string]*cacheEntry
|
||||||
artistCache map[string]*cacheEntry
|
artistCache map[string]*cacheEntry
|
||||||
|
isrcCache map[string]string // trackID -> ISRC cache
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +50,7 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
searchCache: make(map[string]*cacheEntry),
|
searchCache: make(map[string]*cacheEntry),
|
||||||
albumCache: make(map[string]*cacheEntry),
|
albumCache: make(map[string]*cacheEntry),
|
||||||
artistCache: make(map[string]*cacheEntry),
|
artistCache: make(map[string]*cacheEntry),
|
||||||
|
isrcCache: make(map[string]string),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return deezerClient
|
return deezerClient
|
||||||
@@ -60,6 +65,7 @@ type deezerTrack struct {
|
|||||||
DiskNumber int `json:"disk_number"`
|
DiskNumber int `json:"disk_number"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
|
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Album deezerAlbumSimple `json:"album"`
|
Album deezerAlbumSimple `json:"album"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
@@ -82,6 +88,52 @@ type deezerAlbumSimple struct {
|
|||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
|
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||||
|
}
|
||||||
|
// ... (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 {
|
type deezerAlbumFull struct {
|
||||||
@@ -128,6 +180,7 @@ type deezerPlaylistFull struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SearchAll searches for tracks and artists on Deezer
|
// 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) {
|
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)
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
@@ -143,7 +196,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
Artists: make([]SearchArtistResult, 0),
|
Artists: make([]SearchArtistResult, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search tracks
|
// Search tracks - NO ISRC fetch for performance
|
||||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
@@ -153,14 +206,8 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, track := range trackResp.Data {
|
for _, track := range trackResp.Data {
|
||||||
// Fetch full track info to get ISRC (search results don't include ISRC)
|
// Convert directly without fetching ISRC - much faster
|
||||||
fullTrack, err := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
if err == nil && fullTrack != nil {
|
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(*fullTrack))
|
|
||||||
} else {
|
|
||||||
// Fallback to search result without ISRC
|
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search artists
|
// Search artists
|
||||||
@@ -206,6 +253,7 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAlbum fetches album with tracks
|
// GetAlbum fetches album with tracks
|
||||||
|
// ISRC is fetched in parallel for better performance
|
||||||
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
@@ -239,14 +287,13 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||||
for _, track := range album.Tracks.Data {
|
for _, track := range album.Tracks.Data {
|
||||||
// Need to fetch full track info for ISRC
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
|
isrc := isrcMap[trackIDStr]
|
||||||
isrc := ""
|
|
||||||
if fullTrack != nil {
|
|
||||||
isrc = fullTrack.ISRC
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
@@ -368,6 +415,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPlaylist fetches playlist with tracks
|
// GetPlaylist fetches playlist with tracks
|
||||||
|
// ISRC is fetched in parallel for better performance
|
||||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||||
|
|
||||||
@@ -390,6 +438,9 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
info.Owner.Name = playlist.Title
|
info.Owner.Name = playlist.Title
|
||||||
info.Owner.Images = playlistImage
|
info.Owner.Images = playlistImage
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
||||||
for _, track := range playlist.Tracks.Data {
|
for _, track := range playlist.Tracks.Data {
|
||||||
albumImage := track.Album.CoverXL
|
albumImage := track.Album.CoverXL
|
||||||
@@ -400,13 +451,8 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
albumImage = track.Album.CoverMedium
|
albumImage = track.Album.CoverMedium
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch full track for ISRC
|
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||||
fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
|
isrc := isrcMap[trackIDStr]
|
||||||
isrc := ""
|
|
||||||
releaseDate := ""
|
|
||||||
if fullTrack != nil {
|
|
||||||
isrc = fullTrack.ISRC
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||||
@@ -416,7 +462,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
|||||||
AlbumArtist: track.Artist.Name,
|
AlbumArtist: track.Artist.Name,
|
||||||
DurationMS: track.Duration * 1000,
|
DurationMS: track.Duration * 1000,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
ReleaseDate: releaseDate,
|
ReleaseDate: "",
|
||||||
TrackNumber: track.TrackPosition,
|
TrackNumber: track.TrackPosition,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
@@ -472,42 +518,93 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
|||||||
return &track, nil
|
return &track, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
|
||||||
artistName := track.Artist.Name
|
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||||
if len(track.Contributors) > 0 {
|
result := make(map[string]string)
|
||||||
names := make([]string, len(track.Contributors))
|
var resultMu sync.Mutex
|
||||||
for i, a := range track.Contributors {
|
|
||||||
names[i] = a.Name
|
// 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)
|
||||||
}
|
}
|
||||||
artistName = strings.Join(names, ", ")
|
|
||||||
}
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
albumImage := track.Album.CoverXL
|
|
||||||
if albumImage == "" {
|
if len(tracksToFetch) == 0 {
|
||||||
albumImage = track.Album.CoverBig
|
return result
|
||||||
}
|
}
|
||||||
if albumImage == "" {
|
|
||||||
albumImage = track.Album.CoverMedium
|
// Use semaphore to limit concurrent requests
|
||||||
}
|
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||||
if albumImage == "" {
|
var wg sync.WaitGroup
|
||||||
albumImage = track.Album.Cover
|
|
||||||
}
|
for _, track := range tracksToFetch {
|
||||||
|
wg.Add(1)
|
||||||
return TrackMetadata{
|
go func(t deezerTrack) {
|
||||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
defer wg.Done()
|
||||||
Artists: artistName,
|
|
||||||
Name: track.Title,
|
// Acquire semaphore
|
||||||
AlbumName: track.Album.Title,
|
select {
|
||||||
AlbumArtist: track.Artist.Name,
|
case sem <- struct{}{}:
|
||||||
DurationMS: track.Duration * 1000,
|
defer func() { <-sem }()
|
||||||
Images: albumImage,
|
case <-ctx.Done():
|
||||||
TrackNumber: track.TrackPosition,
|
return
|
||||||
DiscNumber: track.DiskNumber,
|
}
|
||||||
ExternalURL: track.Link,
|
|
||||||
ISRC: track.ISRC,
|
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 {
|
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||||
if artist.PictureXL != "" {
|
if artist.PictureXL != "" {
|
||||||
return artist.PictureXL
|
return artist.PictureXL
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,18 +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
|
// Actual quality info from the source
|
||||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Artist string `json:"artist,omitempty"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadResult is a generic result type for all downloaders
|
||||||
// DownloadResult is a generic result type for all downloaders
|
// DownloadResult is a generic result type for all downloaders
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadTrack downloads a track from the specified service
|
// DownloadTrack downloads a track from the specified service
|
||||||
@@ -181,9 +195,15 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
tidalResult, tidalErr := downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
if tidalErr == nil {
|
if tidalErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: tidalResult.FilePath,
|
FilePath: tidalResult.FilePath,
|
||||||
BitDepth: tidalResult.BitDepth,
|
BitDepth: tidalResult.BitDepth,
|
||||||
SampleRate: tidalResult.SampleRate,
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
Title: tidalResult.Title,
|
||||||
|
Artist: tidalResult.Artist,
|
||||||
|
Album: tidalResult.Album,
|
||||||
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
@@ -191,9 +211,15 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
qobuzResult, qobuzErr := downloadFromQobuz(req)
|
||||||
if qobuzErr == nil {
|
if qobuzErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: qobuzResult.FilePath,
|
FilePath: qobuzResult.FilePath,
|
||||||
BitDepth: qobuzResult.BitDepth,
|
BitDepth: qobuzResult.BitDepth,
|
||||||
SampleRate: qobuzResult.SampleRate,
|
SampleRate: qobuzResult.SampleRate,
|
||||||
|
Title: qobuzResult.Title,
|
||||||
|
Artist: qobuzResult.Artist,
|
||||||
|
Album: qobuzResult.Album,
|
||||||
|
ReleaseDate: qobuzResult.ReleaseDate,
|
||||||
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
@@ -201,9 +227,15 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
Artist: amazonResult.Artist,
|
||||||
|
Album: amazonResult.Album,
|
||||||
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
@@ -254,6 +286,12 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
ActualBitDepth: result.BitDepth,
|
ActualBitDepth: result.BitDepth,
|
||||||
ActualSampleRate: result.SampleRate,
|
ActualSampleRate: result.SampleRate,
|
||||||
Service: req.Service,
|
Service: req.Service,
|
||||||
|
Title: result.Title,
|
||||||
|
Artist: result.Artist,
|
||||||
|
Album: result.Album,
|
||||||
|
ReleaseDate: result.ReleaseDate,
|
||||||
|
TrackNumber: result.TrackNumber,
|
||||||
|
DiscNumber: result.DiscNumber,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
@@ -279,7 +317,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
allServices := []string{"qobuz", "tidal", "amazon"}
|
allServices := []string{"qobuz", "tidal", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
preferredService = "qobuz"
|
preferredService = "tidal"
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||||
@@ -308,10 +346,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
tidalResult, tidalErr := downloadFromTidal(req)
|
tidalResult, tidalErr := downloadFromTidal(req)
|
||||||
if tidalErr == nil {
|
if tidalErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: tidalResult.FilePath,
|
FilePath: tidalResult.FilePath,
|
||||||
BitDepth: tidalResult.BitDepth,
|
BitDepth: tidalResult.BitDepth,
|
||||||
SampleRate: tidalResult.SampleRate,
|
SampleRate: tidalResult.SampleRate,
|
||||||
|
Title: tidalResult.Title,
|
||||||
|
Artist: tidalResult.Artist,
|
||||||
|
Album: tidalResult.Album,
|
||||||
|
ReleaseDate: tidalResult.ReleaseDate,
|
||||||
|
TrackNumber: tidalResult.TrackNumber,
|
||||||
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
@@ -322,16 +368,26 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
BitDepth: qobuzResult.BitDepth,
|
BitDepth: qobuzResult.BitDepth,
|
||||||
SampleRate: qobuzResult.SampleRate,
|
SampleRate: qobuzResult.SampleRate,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
case "amazon":
|
case "amazon":
|
||||||
amazonResult, amazonErr := downloadFromAmazon(req)
|
amazonResult, amazonErr := downloadFromAmazon(req)
|
||||||
if amazonErr == nil {
|
if amazonErr == nil {
|
||||||
result = DownloadResult{
|
result = DownloadResult{
|
||||||
FilePath: amazonResult.FilePath,
|
FilePath: amazonResult.FilePath,
|
||||||
BitDepth: amazonResult.BitDepth,
|
BitDepth: amazonResult.BitDepth,
|
||||||
SampleRate: amazonResult.SampleRate,
|
SampleRate: amazonResult.SampleRate,
|
||||||
|
Title: amazonResult.Title,
|
||||||
|
Artist: amazonResult.Artist,
|
||||||
|
Album: amazonResult.Album,
|
||||||
|
ReleaseDate: amazonResult.ReleaseDate,
|
||||||
|
TrackNumber: amazonResult.TrackNumber,
|
||||||
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
}
|
}
|
||||||
@@ -443,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{}
|
||||||
@@ -483,7 +559,7 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsLRC fetches lyrics and converts to LRC format string
|
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
||||||
// First tries to extract from file, then falls back to fetching from internet
|
// First tries to extract from file, then falls back to fetching from internet
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
||||||
// Try to extract from file first (much faster)
|
// Try to extract from file first (much faster)
|
||||||
@@ -501,7 +577,8 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
lrcContent := convertToLRC(lyricsData)
|
// Convert to LRC format with metadata headers (like PC version)
|
||||||
|
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,10 +845,88 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SONGLINK DEEZER SUPPORT ====================
|
||||||
|
|
||||||
|
// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source
|
||||||
|
// Returns JSON with availability info for Spotify, Tidal, Amazon, etc.
|
||||||
|
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||||
|
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
|
||||||
|
|||||||
@@ -12,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
|
||||||
|
|||||||
@@ -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\..*?\)`,
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ type AudioQuality struct {
|
|||||||
|
|
||||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||||
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||||
|
// For M4A files, it delegates to GetM4AQuality
|
||||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -389,45 +390,401 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Read FLAC marker (4 bytes: "fLaC")
|
// Read first 4 bytes to detect file type
|
||||||
marker := make([]byte, 4)
|
marker := make([]byte, 4)
|
||||||
if _, err := file.Read(marker); err != nil {
|
if _, err := file.Read(marker); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
}
|
}
|
||||||
if string(marker) != "fLaC" {
|
|
||||||
return AudioQuality{}, fmt.Errorf("not a FLAC file")
|
// Check if it's a FLAC file
|
||||||
}
|
if string(marker) == "fLaC" {
|
||||||
|
// Continue reading FLAC metadata
|
||||||
|
// Read metadata block header (4 bytes)
|
||||||
|
header := make([]byte, 4)
|
||||||
|
if _, err := file.Read(header); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Read metadata block header (4 bytes)
|
blockType := header[0] & 0x7F
|
||||||
// Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO)
|
if blockType != 0 {
|
||||||
// Bytes 1-3: block length (24-bit big-endian)
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
header := make([]byte, 4)
|
}
|
||||||
if _, err := file.Read(header); err != nil {
|
|
||||||
|
// Read STREAMINFO block (34 bytes minimum)
|
||||||
|
streamInfo := make([]byte, 34)
|
||||||
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse sample rate (20 bits starting at byte 10)
|
||||||
|
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||||
|
|
||||||
|
// Parse bits per sample (5 bits)
|
||||||
|
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||||
|
|
||||||
|
return AudioQuality{
|
||||||
|
BitDepth: bitsPerSample,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
||||||
|
// First 4 bytes are size, next 4 should be "ftyp"
|
||||||
|
file.Seek(0, 0) // Reset to beginning
|
||||||
|
header8 := make([]byte, 8)
|
||||||
|
if _, err := file.Read(header8); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
blockType := header[0] & 0x7F
|
if string(header8[4:8]) == "ftyp" {
|
||||||
if blockType != 0 {
|
// It's an M4A/MP4 file, use M4A quality reader
|
||||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
file.Close() // Close before calling GetM4AQuality which opens the file again
|
||||||
|
return GetM4AQuality(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read STREAMINFO block (34 bytes minimum)
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
// Bytes 10-13 contain sample rate (20 bits), channels (3 bits), bits per sample (5 bits)
|
}
|
||||||
streamInfo := make([]byte, 34)
|
|
||||||
if _, err := file.Read(streamInfo); err != nil {
|
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
// ========================================
|
||||||
}
|
// M4A (MP4/AAC) Metadata Embedding
|
||||||
|
// ========================================
|
||||||
// Parse sample rate (20 bits starting at byte 10)
|
|
||||||
// Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
// This is a simplified implementation that writes metadata to the file
|
||||||
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
// Parse bits per sample (5 bits)
|
// Read the entire file
|
||||||
// Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1
|
data, err := os.ReadFile(filePath)
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
return AudioQuality{
|
}
|
||||||
BitDepth: bitsPerSample,
|
|
||||||
SampleRate: sampleRate,
|
// Find moov atom position
|
||||||
}, nil
|
moovPos := findAtom(data, "moov", 0)
|
||||||
|
if moovPos < 0 {
|
||||||
|
return fmt.Errorf("moov atom not found in M4A file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find udta atom inside moov, or create one
|
||||||
|
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
|
||||||
|
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||||
|
|
||||||
|
// Build new metadata atoms
|
||||||
|
metaAtom := buildMetaAtom(metadata, coverData)
|
||||||
|
|
||||||
|
var newData []byte
|
||||||
|
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||||
|
// udta exists, find meta inside it or replace
|
||||||
|
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
|
||||||
|
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||||
|
|
||||||
|
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||||
|
// Replace existing meta atom
|
||||||
|
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
|
||||||
|
newData = append(newData, data[:metaPos]...)
|
||||||
|
newData = append(newData, metaAtom...)
|
||||||
|
newData = append(newData, data[metaPos+metaSize:]...)
|
||||||
|
} else {
|
||||||
|
// Add meta atom to udta
|
||||||
|
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||||
|
newUdtaSize := 8 + len(newUdtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(newUdtaSize >> 24)
|
||||||
|
newUdta[1] = byte(newUdtaSize >> 16)
|
||||||
|
newUdta[2] = byte(newUdtaSize >> 8)
|
||||||
|
newUdta[3] = byte(newUdtaSize)
|
||||||
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
|
newUdta = append(newUdta, newUdtaContent...)
|
||||||
|
|
||||||
|
newData = append(newData, data[:udtaPos]...)
|
||||||
|
newData = append(newData, newUdta...)
|
||||||
|
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new udta with meta
|
||||||
|
udtaContent := metaAtom
|
||||||
|
udtaSize := 8 + len(udtaContent)
|
||||||
|
newUdta := make([]byte, 4)
|
||||||
|
newUdta[0] = byte(udtaSize >> 24)
|
||||||
|
newUdta[1] = byte(udtaSize >> 16)
|
||||||
|
newUdta[2] = byte(udtaSize >> 8)
|
||||||
|
newUdta[3] = byte(udtaSize)
|
||||||
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
|
newUdta = append(newUdta, udtaContent...)
|
||||||
|
|
||||||
|
// Insert udta at end of moov
|
||||||
|
insertPos := moovPos + moovSize
|
||||||
|
newData = append(newData, data[:insertPos]...)
|
||||||
|
newData = append(newData, newUdta...)
|
||||||
|
newData = append(newData, data[insertPos:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update moov size
|
||||||
|
newMoovSize := moovSize + len(newData) - len(data)
|
||||||
|
newData[moovPos] = byte(newMoovSize >> 24)
|
||||||
|
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||||
|
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||||
|
newData[moovPos+3] = byte(newMoovSize)
|
||||||
|
|
||||||
|
// Write back to file
|
||||||
|
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[M4A] Metadata embedded successfully\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAtom finds an atom by name starting from offset
|
||||||
|
func findAtom(data []byte, name string, offset int) int {
|
||||||
|
for i := offset; i < len(data)-8; {
|
||||||
|
size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
|
||||||
|
if size < 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
atomName := string(data[i+4 : i+8])
|
||||||
|
if atomName == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||||
|
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||||
|
// Build ilst content
|
||||||
|
var ilst []byte
|
||||||
|
|
||||||
|
// ©nam - Title
|
||||||
|
if metadata.Title != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©ART - Artist
|
||||||
|
if metadata.Artist != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©alb - Album
|
||||||
|
if metadata.Album != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aART - Album Artist
|
||||||
|
if metadata.AlbumArtist != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©day - Year/Date
|
||||||
|
if metadata.Date != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trkn - Track Number
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk - Disc Number
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ©lyr - Lyrics
|
||||||
|
if metadata.Lyrics != "" {
|
||||||
|
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// covr - Cover Art
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ilst atom
|
||||||
|
ilstSize := 8 + len(ilst)
|
||||||
|
ilstAtom := make([]byte, 4)
|
||||||
|
ilstAtom[0] = byte(ilstSize >> 24)
|
||||||
|
ilstAtom[1] = byte(ilstSize >> 16)
|
||||||
|
ilstAtom[2] = byte(ilstSize >> 8)
|
||||||
|
ilstAtom[3] = byte(ilstSize)
|
||||||
|
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||||
|
ilstAtom = append(ilstAtom, ilst...)
|
||||||
|
|
||||||
|
// Build hdlr atom (required for meta)
|
||||||
|
hdlr := []byte{
|
||||||
|
0, 0, 0, 33, // size = 33
|
||||||
|
'h', 'd', 'l', 'r',
|
||||||
|
0, 0, 0, 0, // version + flags
|
||||||
|
0, 0, 0, 0, // predefined
|
||||||
|
'm', 'd', 'i', 'r', // handler type
|
||||||
|
'a', 'p', 'p', 'l', // manufacturer
|
||||||
|
0, 0, 0, 0, // component flags
|
||||||
|
0, 0, 0, 0, // component flags mask
|
||||||
|
0, // null terminator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build meta atom
|
||||||
|
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||||
|
metaContent = append(metaContent, ilstAtom...)
|
||||||
|
|
||||||
|
metaSize := 8 + len(metaContent)
|
||||||
|
metaAtom := make([]byte, 4)
|
||||||
|
metaAtom[0] = byte(metaSize >> 24)
|
||||||
|
metaAtom[1] = byte(metaSize >> 16)
|
||||||
|
metaAtom[2] = byte(metaSize >> 8)
|
||||||
|
metaAtom[3] = byte(metaSize)
|
||||||
|
metaAtom = append(metaAtom, []byte("meta")...)
|
||||||
|
metaAtom = append(metaAtom, metaContent...)
|
||||||
|
|
||||||
|
return metaAtom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||||
|
func buildTextAtom(name, value string) []byte {
|
||||||
|
valueBytes := []byte(value)
|
||||||
|
|
||||||
|
// data atom
|
||||||
|
dataSize := 16 + len(valueBytes)
|
||||||
|
dataAtom := make([]byte, 4)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
|
dataAtom = append(dataAtom, valueBytes...)
|
||||||
|
|
||||||
|
// container atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte(name)...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTrackNumberAtom builds trkn atom
|
||||||
|
func buildTrackNumberAtom(track, total int) []byte {
|
||||||
|
// data atom with track number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 24, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(track >> 8), byte(track), // track number
|
||||||
|
byte(total >> 8), byte(total), // total tracks
|
||||||
|
0, 0, // padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// trkn atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("trkn")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiscNumberAtom builds disk atom
|
||||||
|
func buildDiscNumberAtom(disc, total int) []byte {
|
||||||
|
// data atom with disc number
|
||||||
|
dataAtom := []byte{
|
||||||
|
0, 0, 0, 22, // size
|
||||||
|
'd', 'a', 't', 'a',
|
||||||
|
0, 0, 0, 0, // type = implicit
|
||||||
|
0, 0, 0, 0, // locale
|
||||||
|
0, 0, // padding
|
||||||
|
byte(disc >> 8), byte(disc), // disc number
|
||||||
|
byte(total >> 8), byte(total), // total discs
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("disk")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCoverAtom builds covr atom with image data
|
||||||
|
func buildCoverAtom(coverData []byte) []byte {
|
||||||
|
// Detect image type (JPEG = 13, PNG = 14)
|
||||||
|
imageType := byte(13) // default JPEG
|
||||||
|
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||||
|
imageType = 14 // PNG
|
||||||
|
}
|
||||||
|
|
||||||
|
// data atom
|
||||||
|
dataSize := 16 + len(coverData)
|
||||||
|
dataAtom := make([]byte, 4)
|
||||||
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
|
dataAtom[1] = byte(dataSize >> 16)
|
||||||
|
dataAtom[2] = byte(dataSize >> 8)
|
||||||
|
dataAtom[3] = byte(dataSize)
|
||||||
|
dataAtom = append(dataAtom, []byte("data")...)
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
||||||
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
|
dataAtom = append(dataAtom, coverData...)
|
||||||
|
|
||||||
|
// covr atom
|
||||||
|
atomSize := 8 + len(dataAtom)
|
||||||
|
atom := make([]byte, 4)
|
||||||
|
atom[0] = byte(atomSize >> 24)
|
||||||
|
atom[1] = byte(atomSize >> 16)
|
||||||
|
atom[2] = byte(atomSize >> 8)
|
||||||
|
atom[3] = byte(atomSize)
|
||||||
|
atom = append(atom, []byte("covr")...)
|
||||||
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
|
return atom
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetM4AQuality reads audio quality from M4A file
|
||||||
|
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
||||||
|
moovPos := findAtom(data, "moov", 0)
|
||||||
|
if moovPos < 0 {
|
||||||
|
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for mp4a or alac atom which contains audio info
|
||||||
|
// This is a simplified search - real implementation would traverse the atom tree
|
||||||
|
for i := moovPos; i < len(data)-20; i++ {
|
||||||
|
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||||
|
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
||||||
|
if i+24 < len(data) {
|
||||||
|
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
||||||
|
// For AAC, bit depth is typically 16
|
||||||
|
bitDepth := 16
|
||||||
|
if string(data[i:i+4]) == "alac" {
|
||||||
|
// ALAC can have higher bit depth, check esds or alac specific data
|
||||||
|
bitDepth = 24 // Assume 24-bit for ALAC
|
||||||
|
}
|
||||||
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ func FetchCoverAndLyricsParallel(
|
|||||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
result.LyricsData = lyrics
|
result.LyricsData = lyrics
|
||||||
result.LyricsLRC = convertToLRC(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))
|
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||||
} else {
|
} else {
|
||||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadProgress represents current download progress
|
// DownloadProgress represents current download progress
|
||||||
@@ -23,6 +24,7 @@ type ItemProgress struct {
|
|||||||
BytesTotal int64 `json:"bytes_total"`
|
BytesTotal int64 `json:"bytes_total"`
|
||||||
BytesReceived int64 `json:"bytes_received"`
|
BytesReceived int64 `json:"bytes_received"`
|
||||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||||
|
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||||
IsDownloading bool `json:"is_downloading"`
|
IsDownloading bool `json:"is_downloading"`
|
||||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||||
}
|
}
|
||||||
@@ -124,6 +126,20 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetItemBytesReceivedWithSpeed sets bytes received and speed for an item
|
||||||
|
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||||
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
|
item.BytesReceived = received
|
||||||
|
item.SpeedMBps = speedMBps
|
||||||
|
if item.BytesTotal > 0 {
|
||||||
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CompleteItemProgress marks an item as complete
|
// CompleteItemProgress marks an item as complete
|
||||||
func CompleteItemProgress(itemID string) {
|
func CompleteItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
@@ -199,22 +215,29 @@ type ItemProgressWriter struct {
|
|||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
itemID string
|
itemID string
|
||||||
current int64
|
current int64
|
||||||
lastReported int64 // Track last reported bytes for threshold-based updates
|
lastReported int64 // Track last reported bytes for threshold-based updates
|
||||||
|
startTime time.Time // Track start time for speed calculation
|
||||||
|
lastTime time.Time // Track last update time for speed calculation
|
||||||
|
lastBytes int64 // Track bytes at last speed calculation
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
const progressUpdateThreshold = 64 * 1024 // Update progress every 64KB
|
||||||
|
|
||||||
// NewItemProgressWriter creates a new progress writer for a specific item
|
// NewItemProgressWriter creates a new progress writer for a specific item
|
||||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||||
|
now := time.Now()
|
||||||
return &ItemProgressWriter{
|
return &ItemProgressWriter{
|
||||||
writer: w,
|
writer: w,
|
||||||
itemID: itemID,
|
itemID: itemID,
|
||||||
current: 0,
|
current: 0,
|
||||||
lastReported: 0,
|
lastReported: 0,
|
||||||
|
startTime: now,
|
||||||
|
lastTime: now,
|
||||||
|
lastBytes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer with threshold-based progress updates
|
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -225,8 +248,19 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
|||||||
// Update progress when we've received at least 64KB since last update
|
// Update progress when we've received at least 64KB since last update
|
||||||
// Also update on first write to show download has started
|
// Also update on first write to show download has started
|
||||||
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
SetItemBytesReceived(pw.itemID, pw.current)
|
// 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.lastReported = pw.current
|
||||||
|
pw.lastTime = now
|
||||||
|
pw.lastBytes = pw.current
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ func qobuzIsASCIIString(s string) bool {
|
|||||||
return true
|
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)
|
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
||||||
func NewQobuzDownloader() *QobuzDownloader {
|
func NewQobuzDownloader() *QobuzDownloader {
|
||||||
qobuzDownloaderOnce.Do(func() {
|
qobuzDownloaderOnce.Do(func() {
|
||||||
@@ -270,10 +280,11 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||||
|
// Now includes romaji conversion for Japanese text (same as Tidal)
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies
|
// Try multiple search strategies (same as Tidal/PC version)
|
||||||
queries := []string{}
|
queries := []string{}
|
||||||
|
|
||||||
// Strategy 1: Artist + Track name
|
// Strategy 1: Artist + Track name
|
||||||
@@ -286,10 +297,54 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Romaji versions if Japanese detected
|
||||||
|
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||||
|
// Convert to romaji (hiragana/katakana only, kanji stays)
|
||||||
|
romajiTrack := JapaneseToRomaji(trackName)
|
||||||
|
romajiArtist := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
|
// Clean and remove ALL non-ASCII characters (including kanji)
|
||||||
|
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||||
|
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||||
|
|
||||||
|
// Artist + Track romaji (cleaned to ASCII only)
|
||||||
|
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||||
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
|
if !containsQueryQobuz(queries, romajiQuery) {
|
||||||
|
queries = append(queries, romajiQuery)
|
||||||
|
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
|
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 {
|
||||||
@@ -298,6 +353,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +374,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
|
fmt.Printf("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
|
||||||
allTracks = append(allTracks, result.Tracks.Items...)
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -526,9 +583,15 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
|
|
||||||
// QobuzDownloadResult contains download result with quality info
|
// QobuzDownloadResult contains download result with quality info
|
||||||
type QobuzDownloadResult struct {
|
type QobuzDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromQobuz downloads a track using the request parameters
|
// downloadFromQobuz downloads a track using the request parameters
|
||||||
@@ -668,16 +731,17 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using parallel-fetched cover data
|
// 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
// Use cover data from parallel fetch
|
||||||
@@ -703,9 +767,18 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
|
||||||
return QobuzDownloadResult{
|
return QobuzDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: actualBitDepth,
|
BitDepth: actualBitDepth,
|
||||||
SampleRate: actualSampleRate,
|
SampleRate: actualSampleRate,
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: track.Performer.Name,
|
||||||
|
Album: track.Album.Title,
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber, // Qobuz track struct limitations
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hiragana to Romaji mapping
|
||||||
|
var hiraganaToRomaji = map[rune]string{
|
||||||
|
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||||
|
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||||
|
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||||
|
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||||
|
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||||
|
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||||
|
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||||
|
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||||
|
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||||
|
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||||
|
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||||
|
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||||
|
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||||
|
'っ': "", // Double consonant marker
|
||||||
|
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Katakana to Romaji mapping
|
||||||
|
var katakanaToRomaji = map[rune]string{
|
||||||
|
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||||
|
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||||
|
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||||
|
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||||
|
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||||
|
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||||
|
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||||
|
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||||
|
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||||
|
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||||
|
// Dakuten (voiced)
|
||||||
|
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||||
|
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||||
|
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||||
|
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||||
|
// Handakuten (semi-voiced)
|
||||||
|
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||||
|
// Small characters
|
||||||
|
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||||
|
'ッ': "", // Double consonant marker
|
||||||
|
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||||
|
// Extended katakana
|
||||||
|
'ー': "", // Long vowel mark
|
||||||
|
'ヴ': "vu",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combination mappings for きゃ, しゃ, etc.
|
||||||
|
var combinationHiragana = map[string]string{
|
||||||
|
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||||
|
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||||
|
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||||
|
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
||||||
|
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
||||||
|
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
||||||
|
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
||||||
|
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
||||||
|
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
||||||
|
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
||||||
|
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||||
|
}
|
||||||
|
|
||||||
|
var combinationKatakana = map[string]string{
|
||||||
|
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||||
|
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||||
|
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||||
|
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
||||||
|
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
||||||
|
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
||||||
|
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
||||||
|
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
||||||
|
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||||
|
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||||
|
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||||
|
// Extended combinations
|
||||||
|
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||||
|
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||||
|
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsJapanese checks if a string contains Japanese characters
|
||||||
|
func ContainsJapanese(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHiragana(r rune) bool {
|
||||||
|
return r >= 0x3040 && r <= 0x309F
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKatakana(r rune) bool {
|
||||||
|
return r >= 0x30A0 && r <= 0x30FF
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKanji(r rune) bool {
|
||||||
|
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||||
|
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||||
|
}
|
||||||
|
|
||||||
|
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||||
|
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||||
|
func JapaneseToRomaji(text string) string {
|
||||||
|
if !ContainsJapanese(text) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
runes := []rune(text)
|
||||||
|
i := 0
|
||||||
|
|
||||||
|
for i < len(runes) {
|
||||||
|
// Check for っ/ッ (double consonant)
|
||||||
|
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||||
|
nextRomaji := ""
|
||||||
|
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||||
|
nextRomaji = romaji
|
||||||
|
}
|
||||||
|
if len(nextRomaji) > 0 {
|
||||||
|
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for two-character combinations
|
||||||
|
if i < len(runes)-1 {
|
||||||
|
combo := string(runes[i : i+2])
|
||||||
|
if romaji, ok := combinationHiragana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if romaji, ok := combinationKatakana[combo]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single character conversion
|
||||||
|
r := runes[i]
|
||||||
|
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||||
|
result.WriteString(romaji)
|
||||||
|
} else if isKanji(r) {
|
||||||
|
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
// Keep other characters (punctuation, spaces, etc.)
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSearchQuery creates a search query from track name and artist
|
||||||
|
// Converts Japanese to romaji if present
|
||||||
|
func BuildSearchQuery(trackName, artistName string) string {
|
||||||
|
// Convert Japanese to romaji
|
||||||
|
trackRomaji := JapaneseToRomaji(trackName)
|
||||||
|
artistRomaji := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
|
// Clean up the query - remove special characters that might interfere with search
|
||||||
|
trackClean := cleanSearchQuery(trackRomaji)
|
||||||
|
artistClean := cleanSearchQuery(artistRomaji)
|
||||||
|
|
||||||
|
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanSearchQuery removes special characters that might interfere with search
|
||||||
|
func cleanSearchQuery(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||||
|
// This is useful for creating search queries that work better with Tidal's search
|
||||||
|
func CleanToASCII(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
} else if r == ',' || r == '.' {
|
||||||
|
// Convert punctuation to space
|
||||||
|
result.WriteRune(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean up multiple spaces
|
||||||
|
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
@@ -48,6 +48,11 @@ func NewSongLinkClient() *SongLinkClient {
|
|||||||
|
|
||||||
// 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()
|
||||||
|
|
||||||
@@ -71,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)
|
||||||
@@ -114,7 +129,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC
|
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
}
|
}
|
||||||
@@ -282,3 +297,248 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
|
|||||||
|
|
||||||
return availability.DeezerID, nil
|
return availability.DeezerID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Deezer ID Support - Query SongLink using Deezer as source
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
||||||
|
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||||
|
if deezerTrackID == "" {
|
||||||
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build Deezer URL
|
||||||
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
|
// Build API URL using Deezer URL as source
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
EntitiesByUniqueId map[string]struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
} `json:"entitiesByUniqueId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
Deezer: true,
|
||||||
|
DeezerID: deezerTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
// Extract Spotify ID from URL
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer URL
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
||||||
|
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||||
|
// entityType: "song" or "album"
|
||||||
|
// entityID: the ID on that platform
|
||||||
|
func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entityID string) (*TrackAvailability, error) {
|
||||||
|
if entityID == "" {
|
||||||
|
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use global rate limiter
|
||||||
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
|
// Build API URL using platform, type, and id parameters (as per API docs)
|
||||||
|
// https://api.song.link/v1-alpha.1/links?platform=deezer&type=song&id=123456
|
||||||
|
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||||
|
url.QueryEscape(platform),
|
||||||
|
url.QueryEscape(entityType),
|
||||||
|
url.QueryEscape(entityID))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryConfig := DefaultRetryConfig()
|
||||||
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
if resp.StatusCode == 400 {
|
||||||
|
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
|
}
|
||||||
|
if resp.StatusCode == 429 {
|
||||||
|
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ReadResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var songLinkResp struct {
|
||||||
|
LinksByPlatform map[string]struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"linksByPlatform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
|
// Check Spotify
|
||||||
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Tidal
|
||||||
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Amazon
|
||||||
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Deezer
|
||||||
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||||
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
|
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
|
||||||
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
// Get the ID part and remove any query parameters
|
||||||
|
idPart := parts[1]
|
||||||
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
|
idPart = idPart[:idx]
|
||||||
|
}
|
||||||
|
return idPart
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
||||||
|
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Spotify")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.SpotifyID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Tidal || availability.TidalURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Tidal")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.TidalURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
||||||
|
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||||
|
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !availability.Amazon || availability.AmazonURL == "" {
|
||||||
|
return "", fmt.Errorf("track not found on Amazon Music")
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability.AmazonURL, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -495,6 +495,17 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
// Track item structure for pagination
|
||||||
|
type trackItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
ExternalURL externalURL `json:"external_urls"`
|
||||||
|
Artists []artist `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
@@ -502,15 +513,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
Artists []artist `json:"artists"`
|
Artists []artist `json:"artists"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Items []struct {
|
Items []trackItem `json:"items"`
|
||||||
ID string `json:"id"`
|
Next string `json:"next"`
|
||||||
Name string `json:"name"`
|
|
||||||
DurationMS int `json:"duration_ms"`
|
|
||||||
TrackNumber int `json:"track_number"`
|
|
||||||
DiscNumber int `json:"disc_number"`
|
|
||||||
ExternalURL externalURL `json:"external_urls"`
|
|
||||||
Artists []artist `json:"artists"`
|
|
||||||
} `json:"items"`
|
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,10 +531,38 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
// Collect all tracks (including paginated)
|
||||||
for _, item := range data.Tracks.Items {
|
allTrackItems := data.Tracks.Items
|
||||||
// Fetch ISRC for each track
|
nextURL := data.Tracks.Next
|
||||||
isrc := c.fetchTrackISRC(ctx, item.ID, token)
|
|
||||||
|
// Fetch remaining tracks using pagination (no limit)
|
||||||
|
for nextURL != "" {
|
||||||
|
var pageData struct {
|
||||||
|
Items []trackItem `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
|
fmt.Printf("[Spotify] Warning: failed to fetch album tracks page: %v\n", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
allTrackItems = append(allTrackItems, pageData.Items...)
|
||||||
|
nextURL = pageData.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||||
|
|
||||||
|
// Collect track IDs for parallel ISRC fetching
|
||||||
|
trackIDs := make([]string, len(allTrackItems))
|
||||||
|
for i, item := range allTrackItems {
|
||||||
|
trackIDs[i] = item.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
||||||
|
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||||
|
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||||
|
for _, item := range allTrackItems {
|
||||||
|
isrc := isrcMap[item.ID]
|
||||||
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.ID,
|
SpotifyID: item.ID,
|
||||||
@@ -566,6 +598,47 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
||||||
|
// Similar to Deezer implementation for consistency
|
||||||
|
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||||
|
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||||
|
|
||||||
|
result := make(map[string]string)
|
||||||
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
|
if len(trackIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use semaphore to limit concurrent requests
|
||||||
|
sem := make(chan struct{}, maxParallelISRC)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, trackID := range trackIDs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Acquire semaphore
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||||
|
|
||||||
|
resultMu.Lock()
|
||||||
|
result[id] = isrc
|
||||||
|
resultMu.Unlock()
|
||||||
|
}(trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
// First request to get playlist info and first batch of tracks
|
// First request to get playlist info and first batch of tracks
|
||||||
var data struct {
|
var data struct {
|
||||||
@@ -620,11 +693,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch remaining tracks using pagination (up to 1000 tracks max)
|
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||||
nextURL := data.Tracks.Next
|
nextURL := data.Tracks.Next
|
||||||
maxTracks := 1000
|
|
||||||
|
|
||||||
for nextURL != "" && len(tracks) < maxTracks {
|
for nextURL != "" {
|
||||||
var pageData struct {
|
var pageData struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
Track *trackFull `json:"track"`
|
Track *trackFull `json:"track"`
|
||||||
@@ -642,9 +714,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(tracks) >= maxTracks {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
SpotifyID: item.Track.ID,
|
SpotifyID: item.Track.ID,
|
||||||
Artists: joinArtists(item.Track.Artists),
|
Artists: joinArtists(item.Track.Artists),
|
||||||
@@ -835,8 +904,16 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set headers (same as PC version baseHeaders)
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
req.Header.Set("User-Agent", c.userAgent)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
req.Header.Set("sec-ch-ua-platform", "\"Windows\"")
|
||||||
|
req.Header.Set("sec-fetch-dest", "empty")
|
||||||
|
req.Header.Set("sec-fetch-mode", "cors")
|
||||||
|
req.Header.Set("sec-fetch-site", "same-origin")
|
||||||
|
req.Header.Set("Referer", "https://open.spotify.com/")
|
||||||
|
req.Header.Set("Origin", "https://open.spotify.com")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
@@ -863,13 +940,23 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
|||||||
c.rngMu.Lock()
|
c.rngMu.Lock()
|
||||||
defer c.rngMu.Unlock()
|
defer c.rngMu.Unlock()
|
||||||
|
|
||||||
chromeMajor := 80 + c.rng.Intn(25)
|
// Use Mac User-Agent format (same as PC version)
|
||||||
chromeBuild := 3000 + c.rng.Intn(1500)
|
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||||
chromePatch := 60 + c.rng.Intn(65)
|
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||||
|
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
|
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||||
|
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||||
|
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||||
|
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||||
|
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||||
|
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||||
|
macMajor, macMinor,
|
||||||
|
webkitMajor, webkitMinor,
|
||||||
chromeMajor, chromeBuild, chromePatch,
|
chromeMajor, chromeBuild, chromePatch,
|
||||||
|
safariMajor, safariMinor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,14 +128,16 @@ func NewTidalDownloader() *TidalDownloader {
|
|||||||
// GetAvailableAPIs returns list of available Tidal APIs
|
// GetAvailableAPIs returns list of available Tidal APIs
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
"dm9nZWwucXFkbC5zaXRl", // API 1 - vogel.qqdl.site
|
// Priority 1: APIs that return FULL tracks (not PREVIEW)
|
||||||
"bWF1cy5xcWRsLnNpdGU=", // API 2 - maus.qqdl.site
|
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - returns FULL
|
||||||
"aHVuZC5xcWRsLnNpdGU=", // API 3 - hund.qqdl.site
|
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
|
||||||
"a2F0emUucXFkbC5zaXRl", // API 4 - katze.qqdl.site
|
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
||||||
"d29sZi5xcWRsLnNpdGU=", // API 5 - wolf.qqdl.site
|
// Priority 2: qqdl.site APIs (often return PREVIEW only)
|
||||||
"dGlkYWwua2lub3BsdXMub25saW5l", // API 6 - tidal.kinoplus.online
|
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
||||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // API 7 - tidal-api.binimum.org
|
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
||||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // API 8 - triton.squid.wtf
|
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
||||||
|
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
||||||
|
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -367,13 +369,14 @@ func normalizeTitle(title string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||||
|
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build search queries - multiple strategies
|
// Build search queries - multiple strategies (same as PC version)
|
||||||
queries := []string{}
|
queries := []string{}
|
||||||
|
|
||||||
// Strategy 1: Artist + Track name (original)
|
// Strategy 1: Artist + Track name (original)
|
||||||
@@ -386,9 +389,47 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Artist only as last resort
|
// Strategy 3: Romaji versions if Japanese detected (NEW - from PC version)
|
||||||
|
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 !containsQuery(queries, romajiQuery) {
|
||||||
|
queries = append(queries, romajiQuery)
|
||||||
|
fmt.Printf("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track romaji only (cleaned)
|
||||||
|
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||||
|
if !containsQuery(queries, cleanRomajiTrack) {
|
||||||
|
queries = append(queries, cleanRomajiTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try with partial romaji (artist + cleaned track)
|
||||||
|
if artistName != "" && cleanRomajiTrack != "" {
|
||||||
|
partialQuery := artistName + " " + cleanRomajiTrack
|
||||||
|
if !containsQuery(queries, partialQuery) {
|
||||||
|
queries = append(queries, partialQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: Artist only as last resort
|
||||||
if artistName != "" {
|
if artistName != "" {
|
||||||
queries = append(queries, artistName)
|
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||||
|
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
||||||
|
queries = append(queries, artistOnly)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
||||||
@@ -404,6 +445,8 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
searchedQueries[cleanQuery] = true
|
searchedQueries[cleanQuery] = true
|
||||||
|
|
||||||
|
fmt.Printf("[Tidal] Searching for: %s\n", cleanQuery)
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery))
|
searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery))
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
@@ -415,6 +458,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[Tidal] Search error for '%s': %v\n", cleanQuery, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +477,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Items) > 0 {
|
if len(result.Items) > 0 {
|
||||||
|
fmt.Printf("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
||||||
allTracks = append(allTracks, result.Items...)
|
allTracks = append(allTracks, result.Items...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,6 +488,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
|
|
||||||
// Priority 1: Match by ISRC (exact match) WITH title verification
|
// Priority 1: Match by ISRC (exact match) WITH title verification
|
||||||
if spotifyISRC != "" {
|
if spotifyISRC != "" {
|
||||||
|
fmt.Printf("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
||||||
var isrcMatches []*TidalTrack
|
var isrcMatches []*TidalTrack
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
@@ -460,15 +506,15 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
// Allow 30 seconds tolerance for duration
|
// Allow 3 seconds tolerance for duration (same as PC version)
|
||||||
if durationDiff <= 30 {
|
if durationDiff <= 3 {
|
||||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(durationVerifiedMatches) > 0 {
|
if len(durationVerifiedMatches) > 0 {
|
||||||
// Return first duration-verified match
|
// Return first duration-verified match
|
||||||
fmt.Printf("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
fmt.Printf("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||||
return durationVerifiedMatches[0], nil
|
return durationVerifiedMatches[0], nil
|
||||||
}
|
}
|
||||||
@@ -481,11 +527,12 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No duration to verify, just return first ISRC match
|
// No duration to verify, just return first ISRC match
|
||||||
fmt.Printf("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
fmt.Printf("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
return isrcMatches[0], nil
|
return isrcMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ISRC was provided but no match found, return error
|
// If ISRC was provided but no match found, return error
|
||||||
|
fmt.Printf("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
||||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +563,8 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[Tidal] Found via duration match: %s - %s (%s)\n",
|
||||||
|
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
|
||||||
return bestMatch, nil
|
return bestMatch, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,9 +584,22 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
||||||
|
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
|
||||||
|
|
||||||
return bestMatch, nil
|
return bestMatch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// containsQuery checks if a query already exists in the list
|
||||||
|
func containsQuery(queries []string, query string) bool {
|
||||||
|
for _, q := range queries {
|
||||||
|
if q == query {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
||||||
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
|
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
|
||||||
@@ -564,6 +626,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
|
|
||||||
for _, apiURL := range apis {
|
for _, apiURL := range apis {
|
||||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
||||||
|
fmt.Printf("[Tidal] Trying API: %s\n", reqURL)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", reqURL, nil)
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -573,6 +636,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
|
|
||||||
resp, err := DoRequestWithRetry(client, req, retryConfig)
|
resp, err := DoRequestWithRetry(client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[Tidal] API error: %v\n", err)
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -580,13 +644,32 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
|||||||
body, err := ReadResponseBody(resp)
|
body, err := ReadResponseBody(resp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[Tidal] Read body error: %v\n", err)
|
||||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
|
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log response preview
|
||||||
|
bodyPreview := string(body)
|
||||||
|
if len(bodyPreview) > 300 {
|
||||||
|
bodyPreview = bodyPreview[:300] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview)
|
||||||
|
|
||||||
// Try v2 format first (object with manifest)
|
// Try v2 format first (object with manifest)
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
|
fmt.Printf("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
|
||||||
|
apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation)
|
||||||
|
|
||||||
|
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||||
|
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||||
|
fmt.Printf("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
|
||||||
|
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Tidal] ✓ Got FULL track from %s\n", apiURL)
|
||||||
info := TidalDownloadInfo{
|
info := TidalDownloadInfo{
|
||||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||||
BitDepth: v2Response.Data.BitDepth,
|
BitDepth: v2Response.Data.BitDepth,
|
||||||
@@ -642,6 +725,13 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
}
|
}
|
||||||
|
|
||||||
manifestStr := string(manifestBytes)
|
manifestStr := string(manifestBytes)
|
||||||
|
|
||||||
|
// Debug: log first 500 chars of manifest for debugging
|
||||||
|
manifestPreview := manifestStr
|
||||||
|
if len(manifestPreview) > 500 {
|
||||||
|
manifestPreview = manifestPreview[:500] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf("[Tidal] Manifest content: %s\n", manifestPreview)
|
||||||
|
|
||||||
// Check if it's BTS format (JSON) or DASH format (XML)
|
// Check if it's BTS format (JSON) or DASH format (XML)
|
||||||
if strings.HasPrefix(manifestStr, "{") {
|
if strings.HasPrefix(manifestStr, "{") {
|
||||||
@@ -691,21 +781,31 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
// Calculate segment count from timeline
|
// Calculate segment count from timeline
|
||||||
segmentCount := 0
|
segmentCount := 0
|
||||||
for _, seg := range segTemplate.Timeline.Segments {
|
fmt.Printf("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
||||||
|
for i, seg := range segTemplate.Timeline.Segments {
|
||||||
|
fmt.Printf("[Tidal] Segment[%d]: d=%d, r=%d\n", i, seg.Duration, seg.Repeat)
|
||||||
segmentCount += seg.Repeat + 1
|
segmentCount += seg.Repeat + 1
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[Tidal] Segment count from XML: %d\n", segmentCount)
|
||||||
|
|
||||||
// If no segments found via XML, try regex
|
// If no segments found via XML, try regex
|
||||||
if segmentCount == 0 {
|
if segmentCount == 0 {
|
||||||
segRe := regexp.MustCompile(`<S d="\d+"(?: r="(\d+)")?`)
|
fmt.Println("[Tidal] No segments from XML, trying regex...")
|
||||||
|
// Match <S d="..." /> or <S d="..." r="..." />
|
||||||
|
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
|
||||||
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
||||||
for _, match := range matches {
|
fmt.Printf("[Tidal] Regex found %d segment entries\n", len(matches))
|
||||||
|
for i, match := range matches {
|
||||||
repeat := 0
|
repeat := 0
|
||||||
if len(match) > 1 && match[1] != "" {
|
if len(match) > 2 && match[2] != "" {
|
||||||
fmt.Sscanf(match[1], "%d", &repeat)
|
fmt.Sscanf(match[2], "%d", &repeat)
|
||||||
|
}
|
||||||
|
if i < 5 || i == len(matches)-1 {
|
||||||
|
fmt.Printf("[Tidal] Regex segment[%d]: d=%s, r=%d\n", i, match[1], repeat)
|
||||||
}
|
}
|
||||||
segmentCount += repeat + 1
|
segmentCount += repeat + 1
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate media URLs for each segment
|
// Generate media URLs for each segment
|
||||||
@@ -720,12 +820,17 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with progress tracking
|
// DownloadFile downloads a file from URL with progress tracking
|
||||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Handle manifest-based download
|
// Handle manifest-based download (DASH/BTS)
|
||||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||||
|
// Initialize progress tracking for manifest downloads
|
||||||
|
if itemID != "" {
|
||||||
|
StartItemProgress(itemID)
|
||||||
|
defer CompleteItemProgress(itemID)
|
||||||
|
}
|
||||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress for direct downloads
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -798,10 +903,14 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
|
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
|
||||||
|
fmt.Println("[Tidal] Parsing manifest...")
|
||||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[Tidal] Manifest parse error: %v\n", err)
|
||||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
|
||||||
|
directURL != "", initURL != "", len(mediaURLs))
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
@@ -809,26 +918,27 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
|
|
||||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
// If we have a direct URL (BTS format), download directly with progress tracking
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
// Initialize item progress (required for all downloads)
|
fmt.Printf("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||||
if itemID != "" {
|
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
||||||
StartItemProgress(itemID)
|
|
||||||
defer CompleteItemProgress(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", directURL, nil)
|
req, err := http.NewRequest("GET", directURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[Tidal] BTS request creation failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[Tidal] BTS download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
|
fmt.Printf("[Tidal] BTS download HTTP error: %d\n", resp.StatusCode)
|
||||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes for progress tracking
|
// Set total bytes for progress tracking
|
||||||
@@ -870,79 +980,103 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DASH format - download segments to temporary file
|
// DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
|
||||||
// Note: On Android, we can't use ffmpeg, so we'll try to download as M4A
|
// On Android, we can't use ffmpeg, so we save as M4A directly
|
||||||
// and hope the player can handle it, or we save as .m4a instead of .flac
|
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||||
tempPath := outputPath + ".m4a.tmp"
|
fmt.Printf("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||||
out, err := os.Create(tempPath)
|
|
||||||
|
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
|
||||||
|
// We just update progress here based on segment count
|
||||||
|
|
||||||
|
out, err := os.Create(m4aPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
fmt.Printf("[Tidal] Failed to create M4A file: %v\n", err)
|
||||||
|
return fmt.Errorf("failed to create M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download initialization segment
|
// Download initialization segment
|
||||||
|
fmt.Printf("[Tidal] Downloading init segment...\n")
|
||||||
resp, err := client.Get(initURL)
|
resp, err := client.Get(initURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(tempPath)
|
os.Remove(m4aPath)
|
||||||
|
fmt.Printf("[Tidal] Init segment download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download init segment: %w", err)
|
return fmt.Errorf("failed to download init segment: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(tempPath)
|
os.Remove(m4aPath)
|
||||||
|
fmt.Printf("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
||||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
_, err = io.Copy(out, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(tempPath)
|
os.Remove(m4aPath)
|
||||||
|
fmt.Printf("[Tidal] Init segment write failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to write init segment: %w", err)
|
return fmt.Errorf("failed to write init segment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download media segments
|
// Download media segments with progress
|
||||||
|
totalSegments := len(mediaURLs)
|
||||||
for i, mediaURL := range mediaURLs {
|
for i, mediaURL := range mediaURLs {
|
||||||
|
if i%10 == 0 || i == totalSegments-1 {
|
||||||
|
fmt.Printf("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress based on segment count
|
||||||
|
if itemID != "" {
|
||||||
|
progress := float64(i+1) / float64(totalSegments)
|
||||||
|
SetItemProgress(itemID, progress, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := client.Get(mediaURL)
|
resp, err := client.Get(mediaURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(tempPath)
|
os.Remove(m4aPath)
|
||||||
|
fmt.Printf("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(tempPath)
|
os.Remove(m4aPath)
|
||||||
|
fmt.Printf("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
||||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||||
}
|
}
|
||||||
_, err = io.Copy(out, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(tempPath)
|
os.Remove(m4aPath)
|
||||||
|
fmt.Printf("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Close()
|
if err := out.Close(); err != nil {
|
||||||
|
os.Remove(m4aPath)
|
||||||
// For Android, we'll save as M4A since we can't use ffmpeg
|
fmt.Printf("[Tidal] Failed to close M4A file: %v\n", err)
|
||||||
// Rename temp file to final output (change extension to .m4a if needed)
|
return fmt.Errorf("failed to close M4A file: %w", err)
|
||||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
|
||||||
if err := os.Rename(tempPath, m4aPath); err != nil {
|
|
||||||
os.Remove(tempPath)
|
|
||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the original output was .flac, we need to indicate this is actually m4a
|
fmt.Printf("[Tidal] DASH download completed: %s\n", m4aPath)
|
||||||
// For now, we'll just keep it as m4a
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TidalDownloadResult contains download result with quality info
|
// TidalDownloadResult contains download result with quality info
|
||||||
type TidalDownloadResult struct {
|
type TidalDownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
BitDepth int
|
BitDepth int
|
||||||
SampleRate int
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
}
|
}
|
||||||
|
|
||||||
// artistsMatch checks if the artist names are similar enough
|
// artistsMatch checks if the artist names are similar enough
|
||||||
@@ -1086,8 +1220,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
// Allow 30 seconds tolerance
|
// Allow 3 seconds tolerance (same as PC version)
|
||||||
if durationDiff > 30 {
|
if durationDiff > 3 {
|
||||||
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||||
expectedDurationSec, track.Duration)
|
expectedDurationSec, track.Duration)
|
||||||
track = nil // Reject this match
|
track = nil // Reject this match
|
||||||
@@ -1156,10 +1290,21 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
filename = sanitizeFilename(filename) + ".flac"
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
outputPath := filepath.Join(req.OutputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists
|
// Check if file already exists (both FLAC and M4A)
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
|
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||||
|
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
|
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any leftover .tmp files from previous failed downloads
|
||||||
|
tmpPath := outputPath + ".m4a.tmp"
|
||||||
|
if _, err := os.Stat(tmpPath); err == nil {
|
||||||
|
fmt.Printf("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
// Determine quality to use (default to LOSSLESS if not specified)
|
// Determine quality to use (default to LOSSLESS if not specified)
|
||||||
quality := req.Quality
|
quality := req.Quality
|
||||||
@@ -1193,9 +1338,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
// Download audio file with item ID for progress tracking
|
||||||
|
fmt.Printf("[Tidal] Starting download to: %s\n", outputPath)
|
||||||
|
fmt.Printf("[Tidal] Download URL type: %s\n", func() string {
|
||||||
|
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
||||||
|
return "MANIFEST (DASH/BTS)"
|
||||||
|
}
|
||||||
|
return "Direct URL"
|
||||||
|
}())
|
||||||
|
|
||||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||||
|
fmt.Printf("[Tidal] Download failed with error: %v\n", err)
|
||||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
fmt.Println("[Tidal] Download completed successfully")
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
@@ -1208,9 +1363,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
||||||
// downloadFromManifest saves DASH streams as .m4a
|
// downloadFromManifest saves DASH streams as .m4a (m4aPath already defined above)
|
||||||
actualOutputPath := outputPath
|
actualOutputPath := outputPath
|
||||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
|
||||||
if _, err := os.Stat(m4aPath); err == nil {
|
if _, err := os.Stat(m4aPath); err == nil {
|
||||||
// File was saved as M4A, use that path
|
// File was saved as M4A, use that path
|
||||||
actualOutputPath = m4aPath
|
actualOutputPath = m4aPath
|
||||||
@@ -1240,7 +1394,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only embed metadata to FLAC files (M4A will be converted by Flutter)
|
// Embed metadata based on file type
|
||||||
if strings.HasSuffix(actualOutputPath, ".flac") {
|
if strings.HasSuffix(actualOutputPath, ".flac") {
|
||||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||||
@@ -1257,13 +1411,40 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
} else if req.EmbedLyrics {
|
} else if req.EmbedLyrics {
|
||||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
} else {
|
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||||
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
|
// Embed metadata to M4A file
|
||||||
|
// fmt.Printf("[Tidal] Embedding metadata to M4A file...\n")
|
||||||
|
|
||||||
|
// Add lyrics to metadata if available
|
||||||
|
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
// metadata.Lyrics = parallelResult.LyricsLRC
|
||||||
|
// }
|
||||||
|
|
||||||
|
// SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion
|
||||||
|
// M4A files from DASH are often fragmented and editing metadata might corrupt the container
|
||||||
|
// structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter.
|
||||||
|
|
||||||
|
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||||
|
|
||||||
|
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
|
||||||
|
// fmt.Printf("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
|
||||||
|
// } else {
|
||||||
|
// fmt.Println("[Tidal] M4A metadata embedded successfully")
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to ISRC index for fast duplicate checking
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||||
|
|
||||||
return TidalDownloadResult{
|
return TidalDownloadResult{
|
||||||
FilePath: actualOutputPath,
|
FilePath: actualOutputPath,
|
||||||
BitDepth: downloadInfo.BitDepth,
|
BitDepth: downloadInfo.BitDepth,
|
||||||
SampleRate: downloadInfo.SampleRate,
|
SampleRate: downloadInfo.SampleRate,
|
||||||
|
Title: track.Title,
|
||||||
|
Artist: track.Artist.Name,
|
||||||
|
Album: track.Album.Title,
|
||||||
|
ReleaseDate: track.Album.ReleaseDate,
|
||||||
|
TrackNumber: track.TrackNumber,
|
||||||
|
DiscNumber: track.VolumeNumber,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -427,7 +427,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|||||||
@@ -181,6 +181,67 @@ import Gobackend // Import Go framework
|
|||||||
GobackendCleanupConnections()
|
GobackendCleanupConnections()
|
||||||
return nil
|
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 |
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.1.5';
|
static const String version = '2.1.7';
|
||||||
static const String buildNumber = '43';
|
static const String buildNumber = '45';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ enum DownloadStatus {
|
|||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Error type enum for better error handling
|
||||||
|
enum DownloadErrorType {
|
||||||
|
unknown,
|
||||||
|
notFound, // Track not found on any service
|
||||||
|
rateLimit, // Rate limited by service
|
||||||
|
network, // Network/connection error
|
||||||
|
}
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class DownloadItem {
|
class DownloadItem {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -20,8 +28,10 @@ class DownloadItem {
|
|||||||
final String service;
|
final String service;
|
||||||
final DownloadStatus status;
|
final DownloadStatus status;
|
||||||
final double progress;
|
final double progress;
|
||||||
|
final double speedMBps; // Download speed in MB/s
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final DownloadErrorType? errorType;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? qualityOverride; // Override quality for this specific download
|
final String? qualityOverride; // Override quality for this specific download
|
||||||
|
|
||||||
@@ -31,8 +41,10 @@ class DownloadItem {
|
|||||||
required this.service,
|
required this.service,
|
||||||
this.status = DownloadStatus.queued,
|
this.status = DownloadStatus.queued,
|
||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
|
this.speedMBps = 0.0,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.errorType,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.qualityOverride,
|
this.qualityOverride,
|
||||||
});
|
});
|
||||||
@@ -43,8 +55,10 @@ class DownloadItem {
|
|||||||
String? service,
|
String? service,
|
||||||
DownloadStatus? status,
|
DownloadStatus? status,
|
||||||
double? progress,
|
double? progress,
|
||||||
|
double? speedMBps,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
|
DownloadErrorType? errorType,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
String? qualityOverride,
|
String? qualityOverride,
|
||||||
}) {
|
}) {
|
||||||
@@ -54,13 +68,31 @@ class DownloadItem {
|
|||||||
service: service ?? this.service,
|
service: service ?? this.service,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
progress: progress ?? this.progress,
|
progress: progress ?? this.progress,
|
||||||
|
speedMBps: speedMBps ?? this.speedMBps,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
|
errorType: errorType ?? this.errorType,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
qualityOverride: qualityOverride ?? this.qualityOverride,
|
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get user-friendly error message based on error type
|
||||||
|
String get errorMessage {
|
||||||
|
if (error == null) return '';
|
||||||
|
|
||||||
|
switch (errorType) {
|
||||||
|
case DownloadErrorType.notFound:
|
||||||
|
return 'Song not found on any service';
|
||||||
|
case DownloadErrorType.rateLimit:
|
||||||
|
return 'Rate limit reached, try again later';
|
||||||
|
case DownloadErrorType.network:
|
||||||
|
return 'Connection failed, check your internet';
|
||||||
|
default:
|
||||||
|
return error ?? 'An error occurred';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
factory DownloadItem.fromJson(Map<String, dynamic> json) =>
|
||||||
_$DownloadItemFromJson(json);
|
_$DownloadItemFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
|
||||||
DownloadStatus.queued,
|
DownloadStatus.queued,
|
||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
qualityOverride: json['qualityOverride'] as String?,
|
qualityOverride: json['qualityOverride'] as String?,
|
||||||
);
|
);
|
||||||
@@ -27,8 +29,10 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'service': instance.service,
|
'service': instance.service,
|
||||||
'status': _$DownloadStatusEnumMap[instance.status]!,
|
'status': _$DownloadStatusEnumMap[instance.status]!,
|
||||||
'progress': instance.progress,
|
'progress': instance.progress,
|
||||||
|
'speedMBps': instance.speedMBps,
|
||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
|
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
'qualityOverride': instance.qualityOverride,
|
'qualityOverride': instance.qualityOverride,
|
||||||
};
|
};
|
||||||
@@ -41,3 +45,10 @@ const _$DownloadStatusEnumMap = {
|
|||||||
DownloadStatus.failed: 'failed',
|
DownloadStatus.failed: 'failed',
|
||||||
DownloadStatus.skipped: 'skipped',
|
DownloadStatus.skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _$DownloadErrorTypeEnumMap = {
|
||||||
|
DownloadErrorType.unknown: 'unknown',
|
||||||
|
DownloadErrorType.notFound: 'notFound',
|
||||||
|
DownloadErrorType.rateLimit: 'rateLimit',
|
||||||
|
DownloadErrorType.network: 'network',
|
||||||
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Track {
|
|||||||
final int? trackNumber;
|
final int? trackNumber;
|
||||||
final int? discNumber;
|
final int? discNumber;
|
||||||
final String? releaseDate;
|
final String? releaseDate;
|
||||||
|
final String? deezerId;
|
||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
@@ -30,6 +31,7 @@ class Track {
|
|||||||
this.trackNumber,
|
this.trackNumber,
|
||||||
this.discNumber,
|
this.discNumber,
|
||||||
this.releaseDate,
|
this.releaseDate,
|
||||||
|
this.deezerId,
|
||||||
this.availability,
|
this.availability,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,17 +44,23 @@ class ServiceAvailability {
|
|||||||
final bool tidal;
|
final bool tidal;
|
||||||
final bool qobuz;
|
final bool qobuz;
|
||||||
final bool amazon;
|
final bool amazon;
|
||||||
|
final bool deezer;
|
||||||
final String? tidalUrl;
|
final String? tidalUrl;
|
||||||
final String? qobuzUrl;
|
final String? qobuzUrl;
|
||||||
final String? amazonUrl;
|
final String? amazonUrl;
|
||||||
|
final String? deezerUrl;
|
||||||
|
final String? deezerId;
|
||||||
|
|
||||||
const ServiceAvailability({
|
const ServiceAvailability({
|
||||||
this.tidal = false,
|
this.tidal = false,
|
||||||
this.qobuz = false,
|
this.qobuz = false,
|
||||||
this.amazon = false,
|
this.amazon = false,
|
||||||
|
this.deezer = false,
|
||||||
this.tidalUrl,
|
this.tidalUrl,
|
||||||
this.qobuzUrl,
|
this.qobuzUrl,
|
||||||
this.amazonUrl,
|
this.amazonUrl,
|
||||||
|
this.deezerUrl,
|
||||||
|
this.deezerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
factory ServiceAvailability.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||||
releaseDate: json['releaseDate'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
|
deezerId: json['deezerId'] as String?,
|
||||||
availability: json['availability'] == null
|
availability: json['availability'] == null
|
||||||
? null
|
? null
|
||||||
: ServiceAvailability.fromJson(
|
: ServiceAvailability.fromJson(
|
||||||
@@ -37,6 +38,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'trackNumber': instance.trackNumber,
|
'trackNumber': instance.trackNumber,
|
||||||
'discNumber': instance.discNumber,
|
'discNumber': instance.discNumber,
|
||||||
'releaseDate': instance.releaseDate,
|
'releaseDate': instance.releaseDate,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,9 +47,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
|||||||
tidal: json['tidal'] as bool? ?? false,
|
tidal: json['tidal'] as bool? ?? false,
|
||||||
qobuz: json['qobuz'] as bool? ?? false,
|
qobuz: json['qobuz'] as bool? ?? false,
|
||||||
amazon: json['amazon'] as bool? ?? false,
|
amazon: json['amazon'] as bool? ?? false,
|
||||||
|
deezer: json['deezer'] as bool? ?? false,
|
||||||
tidalUrl: json['tidalUrl'] as String?,
|
tidalUrl: json['tidalUrl'] as String?,
|
||||||
qobuzUrl: json['qobuzUrl'] as String?,
|
qobuzUrl: json['qobuzUrl'] as String?,
|
||||||
amazonUrl: json['amazonUrl'] as String?,
|
amazonUrl: json['amazonUrl'] as String?,
|
||||||
|
deezerUrl: json['deezerUrl'] as String?,
|
||||||
|
deezerId: json['deezerId'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
Map<String, dynamic> _$ServiceAvailabilityToJson(
|
||||||
@@ -56,7 +61,10 @@ Map<String, dynamic> _$ServiceAvailabilityToJson(
|
|||||||
'tidal': instance.tidal,
|
'tidal': instance.tidal,
|
||||||
'qobuz': instance.qobuz,
|
'qobuz': instance.qobuz,
|
||||||
'amazon': instance.amazon,
|
'amazon': instance.amazon,
|
||||||
|
'deezer': instance.deezer,
|
||||||
'tidalUrl': instance.tidalUrl,
|
'tidalUrl': instance.tidalUrl,
|
||||||
'qobuzUrl': instance.qobuzUrl,
|
'qobuzUrl': instance.qobuzUrl,
|
||||||
'amazonUrl': instance.amazonUrl,
|
'amazonUrl': instance.amazonUrl,
|
||||||
|
'deezerUrl': instance.deezerUrl,
|
||||||
|
'deezerId': instance.deezerId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final itemProgress = entry.value as Map<String, dynamic>;
|
final itemProgress = entry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||||
|
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
|
||||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
||||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||||
|
|
||||||
@@ -389,14 +390,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDownloading && bytesTotal > 0) {
|
// Use progress from backend if available (handles both explicit progress and byte-based)
|
||||||
final percentage = bytesReceived / bytesTotal;
|
final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
updateProgress(itemId, percentage);
|
|
||||||
|
if (isDownloading) {
|
||||||
|
double percentage = 0.0;
|
||||||
|
if (bytesTotal > 0) {
|
||||||
|
// Calculate from bytes if available for precision
|
||||||
|
percentage = bytesReceived / bytesTotal;
|
||||||
|
} else {
|
||||||
|
// Fallback to backend-reported progress (e.g. for DASH segments)
|
||||||
|
percentage = progressFromBackend;
|
||||||
|
}
|
||||||
|
|
||||||
// Log progress for each item
|
updateProgress(itemId, percentage, speedMBps: speedMBps);
|
||||||
|
|
||||||
|
// Log progress for each item with speed
|
||||||
final mbReceived = bytesReceived / (1024 * 1024);
|
final mbReceived = bytesReceived / (1024 * 1024);
|
||||||
final mbTotal = bytesTotal / (1024 * 1024);
|
final mbTotal = bytesTotal / (1024 * 1024);
|
||||||
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)');
|
if (bytesTotal > 0) {
|
||||||
|
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s');
|
||||||
|
} else {
|
||||||
|
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,11 +443,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
? downloadingItems.first.track.artistName
|
? downloadingItems.first.track.artistName
|
||||||
: 'Downloading...';
|
: 'Downloading...';
|
||||||
|
|
||||||
|
// Calculate notification progress values
|
||||||
|
int notifProgress = bytesReceived;
|
||||||
|
int notifTotal = bytesTotal;
|
||||||
|
|
||||||
|
if (bytesTotal <= 0) {
|
||||||
|
// Fallback to percentage for DASH/unknown size
|
||||||
|
final progressPercent = (firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
notifProgress = (progressPercent * 100).toInt();
|
||||||
|
notifTotal = 100;
|
||||||
|
}
|
||||||
|
|
||||||
_notificationService.showDownloadProgress(
|
_notificationService.showDownloadProgress(
|
||||||
trackName: trackName,
|
trackName: trackName,
|
||||||
artistName: artistName,
|
artistName: artistName,
|
||||||
progress: bytesReceived,
|
progress: notifProgress,
|
||||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
total: notifTotal > 0 ? notifTotal : 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update foreground service notification (Android)
|
// Update foreground service notification (Android)
|
||||||
@@ -439,8 +466,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
PlatformBridge.updateDownloadServiceProgress(
|
PlatformBridge.updateDownloadServiceProgress(
|
||||||
trackName: downloadingItems.first.track.name,
|
trackName: downloadingItems.first.track.name,
|
||||||
artistName: downloadingItems.first.track.artistName,
|
artistName: downloadingItems.first.track.artistName,
|
||||||
progress: bytesReceived,
|
progress: notifProgress,
|
||||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
total: notifTotal > 0 ? notifTotal : 1,
|
||||||
queueCount: state.queuedCount,
|
queueCount: state.queuedCount,
|
||||||
).catchError((_) {}); // Ignore errors
|
).catchError((_) {}); // Ignore errors
|
||||||
}
|
}
|
||||||
@@ -609,14 +636,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateItemStatus(String id, DownloadStatus status, {double? progress, String? filePath, String? error}) {
|
void updateItemStatus(String id, DownloadStatus status, {double? progress, double? speedMBps, String? filePath, String? error, DownloadErrorType? errorType}) {
|
||||||
final items = state.items.map((item) {
|
final items = state.items.map((item) {
|
||||||
if (item.id == id) {
|
if (item.id == id) {
|
||||||
return item.copyWith(
|
return item.copyWith(
|
||||||
status: status,
|
status: status,
|
||||||
progress: progress ?? item.progress,
|
progress: progress ?? item.progress,
|
||||||
|
speedMBps: speedMBps ?? item.speedMBps,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
error: error,
|
error: error,
|
||||||
|
errorType: errorType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
@@ -632,8 +661,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateProgress(String id, double progress) {
|
void updateProgress(String id, double progress, {double? speedMBps}) {
|
||||||
updateItemStatus(id, DownloadStatus.downloading, progress: progress);
|
updateItemStatus(id, DownloadStatus.downloading, progress: progress, speedMBps: speedMBps);
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelItem(String id) {
|
void cancelItem(String id) {
|
||||||
@@ -732,18 +761,21 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Download cover first
|
// Download cover first
|
||||||
String? coverPath;
|
String? coverPath;
|
||||||
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
|
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
|
||||||
coverPath = '$flacPath.cover.jpg';
|
|
||||||
try {
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
|
||||||
|
|
||||||
// Download cover using HTTP
|
// Download cover using HTTP
|
||||||
final httpClient = HttpClient();
|
final httpClient = HttpClient();
|
||||||
final request = await httpClient.getUrl(Uri.parse(track.coverUrl!));
|
final request = await httpClient.getUrl(Uri.parse(track.coverUrl!));
|
||||||
final response = await request.close();
|
final response = await request.close();
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final file = File(coverPath);
|
final file = File(coverPath!);
|
||||||
final sink = file.openWrite();
|
final sink = file.openWrite();
|
||||||
await response.pipe(sink);
|
await response.pipe(sink);
|
||||||
await sink.close();
|
await sink.close();
|
||||||
_log.d('Cover downloaded to: $coverPath');
|
_log.d('Cover downloaded to temp: $coverPath');
|
||||||
} else {
|
} else {
|
||||||
_log.w('Failed to download cover: HTTP ${response.statusCode}');
|
_log.w('Failed to download cover: HTTP ${response.statusCode}');
|
||||||
coverPath = null;
|
coverPath = null;
|
||||||
@@ -757,20 +789,85 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
// Use Go backend to embed metadata
|
// Use Go backend to embed metadata
|
||||||
try {
|
try {
|
||||||
// For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
|
// Use FFmpeg to embed cover art AND text metadata
|
||||||
// FFmpeg can embed cover art to FLAC
|
// FFmpeg can embed cover art to FLAC and also set tags
|
||||||
if (coverPath != null && await File(coverPath).exists()) {
|
|
||||||
final result = await FFmpegService.embedCover(flacPath, coverPath);
|
// Construct metadata map
|
||||||
|
final metadata = <String, String>{
|
||||||
|
'TITLE': track.name,
|
||||||
|
'ARTIST': track.artistName,
|
||||||
|
'ALBUM': track.albumName,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (track.albumArtist != null) {
|
||||||
|
metadata['ALBUMARTIST'] = track.albumArtist!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.trackNumber != null) {
|
||||||
|
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||||
|
metadata['TRACK'] = track.trackNumber.toString(); // Compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.discNumber != null) {
|
||||||
|
metadata['DISCNUMBER'] = track.discNumber.toString();
|
||||||
|
metadata['DISC'] = track.discNumber.toString(); // Compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.releaseDate != null) {
|
||||||
|
metadata['DATE'] = track.releaseDate!;
|
||||||
|
metadata['YEAR'] = track.releaseDate!.split('-').first;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.isrc != null) {
|
||||||
|
metadata['ISRC'] = track.isrc!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
|
||||||
|
// Since we are in the Flutter context, we can call the bridge to get lyrics
|
||||||
|
// This ensures even converted files have lyrics embedded if available
|
||||||
|
try {
|
||||||
|
final lrcContent = await PlatformBridge.getLyricsLRC(
|
||||||
|
track.id, // spotifyID
|
||||||
|
track.name,
|
||||||
|
track.artistName,
|
||||||
|
filePath: '', // No local file path yet (processed in memory)
|
||||||
|
);
|
||||||
|
|
||||||
if (result != null) {
|
if (lrcContent != null && lrcContent.isNotEmpty) {
|
||||||
_log.d('Cover embedded via FFmpeg');
|
metadata['LYRICS'] = lrcContent;
|
||||||
} else {
|
metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players
|
||||||
_log.w('FFmpeg cover embed failed');
|
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
// Clean up cover file
|
_log.w('Failed to fetch lyrics for embedding: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.d('Generating tags for FLAC: $metadata');
|
||||||
|
|
||||||
|
// Perform embedding (cover + text metadata)
|
||||||
|
// Note: FFmpegService.embedMetadata handles safe temp file creation
|
||||||
|
final result = await FFmpegService.embedMetadata(
|
||||||
|
flacPath: flacPath,
|
||||||
|
coverPath: coverPath != null && await File(coverPath).exists() ? coverPath : null,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
_log.d('Metadata and cover embedded via FFmpeg');
|
||||||
|
} else {
|
||||||
|
_log.w('FFmpeg metadata/cover embed failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cover file if it exists
|
||||||
|
if (coverPath != null) {
|
||||||
try {
|
try {
|
||||||
await File(coverPath).delete();
|
final coverFile = File(coverPath);
|
||||||
|
if (await coverFile.exists()) {
|
||||||
|
// In Android 10+ scoped storage, we can't easily delete if we didn't create it
|
||||||
|
// in this session or if it's not in our app dir.
|
||||||
|
// But coverPath is typically in temp dir now.
|
||||||
|
await coverFile.delete();
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -992,7 +1089,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
try {
|
try {
|
||||||
// Get folder organization setting and build output directory
|
// Get folder organization setting and build output directory
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final outputDir = await _buildOutputDir(item.track, settings.folderOrganization);
|
|
||||||
|
// Metadata Enrichment:
|
||||||
|
// If track number is missing/0 (common from Search results), fetch full metadata
|
||||||
|
// This ensures the downloaded file has correct tags (Track, Disc, Year)
|
||||||
|
Track trackToDownload = item.track;
|
||||||
|
if (trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0) {
|
||||||
|
try {
|
||||||
|
if (trackToDownload.id.startsWith('deezer:')) {
|
||||||
|
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
|
||||||
|
final rawId = trackToDownload.id.split(':')[1];
|
||||||
|
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
|
||||||
|
|
||||||
|
if (fullData.containsKey('track')) {
|
||||||
|
final fullTrack = Track.fromJson(fullData['track'] as Map<String, dynamic>);
|
||||||
|
// Merge with existing (keep override quality/service if any, but update metadata)
|
||||||
|
trackToDownload = Track(
|
||||||
|
id: fullTrack.id.isNotEmpty ? fullTrack.id : trackToDownload.id,
|
||||||
|
name: fullTrack.name,
|
||||||
|
artistName: fullTrack.artistName,
|
||||||
|
albumName: fullTrack.albumName,
|
||||||
|
albumArtist: fullTrack.albumArtist,
|
||||||
|
coverUrl: fullTrack.coverUrl,
|
||||||
|
duration: fullTrack.duration,
|
||||||
|
isrc: fullTrack.isrc ?? trackToDownload.isrc,
|
||||||
|
trackNumber: fullTrack.trackNumber,
|
||||||
|
discNumber: fullTrack.discNumber,
|
||||||
|
releaseDate: fullTrack.releaseDate,
|
||||||
|
deezerId: fullTrack.deezerId,
|
||||||
|
availability: trackToDownload.availability,
|
||||||
|
);
|
||||||
|
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, Year ${trackToDownload.releaseDate}');
|
||||||
|
|
||||||
|
// Update item in state with enriched track
|
||||||
|
// This is important so the UI (and history) reflects the enriched data
|
||||||
|
// We don't perform a full `updateItemStatus` here to avoid UI flicker, just local var
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Failed to enrich metadata: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final outputDir = await _buildOutputDir(trackToDownload, settings.folderOrganization);
|
||||||
|
|
||||||
// Use quality override if set, otherwise use default from settings
|
// Use quality override if set, otherwise use default from settings
|
||||||
final quality = item.qualityOverride ?? state.audioQuality;
|
final quality = item.qualityOverride ?? state.audioQuality;
|
||||||
@@ -1004,41 +1143,41 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
|
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
|
||||||
_log.d('Output dir: $outputDir');
|
_log.d('Output dir: $outputDir');
|
||||||
result = await PlatformBridge.downloadWithFallback(
|
result = await PlatformBridge.downloadWithFallback(
|
||||||
isrc: item.track.isrc ?? '',
|
isrc: trackToDownload.isrc ?? '',
|
||||||
spotifyId: item.track.id,
|
spotifyId: trackToDownload.id,
|
||||||
trackName: item.track.name,
|
trackName: trackToDownload.name,
|
||||||
artistName: item.track.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: item.track.albumName,
|
albumName: trackToDownload.albumName,
|
||||||
albumArtist: item.track.albumArtist,
|
albumArtist: trackToDownload.albumArtist,
|
||||||
coverUrl: item.track.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
trackNumber: item.track.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
preferredService: item.service,
|
preferredService: item.service,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
durationMs: item.track.duration, // Duration in ms for verification
|
durationMs: trackToDownload.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await PlatformBridge.downloadTrack(
|
result = await PlatformBridge.downloadTrack(
|
||||||
isrc: item.track.isrc ?? '',
|
isrc: trackToDownload.isrc ?? '',
|
||||||
service: item.service,
|
service: item.service,
|
||||||
spotifyId: item.track.id,
|
spotifyId: trackToDownload.id,
|
||||||
trackName: item.track.name,
|
trackName: trackToDownload.name,
|
||||||
artistName: item.track.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: item.track.albumName,
|
albumName: trackToDownload.albumName,
|
||||||
albumArtist: item.track.albumArtist,
|
albumArtist: trackToDownload.albumArtist,
|
||||||
coverUrl: item.track.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
outputDir: outputDir,
|
outputDir: outputDir,
|
||||||
filenameFormat: state.filenameFormat,
|
filenameFormat: state.filenameFormat,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
trackNumber: item.track.trackNumber ?? 1,
|
trackNumber: trackToDownload.trackNumber ?? 1,
|
||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
durationMs: item.track.duration, // Duration in ms for verification
|
durationMs: trackToDownload.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,26 +1221,74 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.i('Actual quality: $actualQuality');
|
_log.i('Actual quality: $actualQuality');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
|
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||||
_log.d('Converting M4A to FLAC...');
|
if (filePath != null && filePath!.endsWith('.m4a')) {
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
_log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...');
|
||||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
|
||||||
if (flacPath != null) {
|
try {
|
||||||
filePath = flacPath;
|
final file = File(filePath!);
|
||||||
_log.d('Converted to: $flacPath');
|
if (!await file.exists()) {
|
||||||
|
_log.e('File does not exist at path: $filePath');
|
||||||
// After conversion, embed metadata and cover to the new FLAC file
|
} else {
|
||||||
_log.d('Embedding metadata and cover to converted FLAC...');
|
final length = await file.length();
|
||||||
try {
|
_log.i('File size before conversion: ${length / 1024} KB');
|
||||||
await _embedMetadataAndCover(
|
|
||||||
flacPath,
|
if (length < 1024) {
|
||||||
item.track,
|
_log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.');
|
||||||
);
|
} else {
|
||||||
_log.d('Metadata and cover embedded successfully');
|
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95);
|
||||||
} catch (e) {
|
final flacPath = await FFmpegService.convertM4aToFlac(filePath!);
|
||||||
_log.w('Warning: Failed to embed metadata/cover: $e');
|
|
||||||
|
if (flacPath != null) {
|
||||||
|
filePath = flacPath;
|
||||||
|
_log.d('Converted to FLAC: $flacPath');
|
||||||
|
|
||||||
|
// After conversion, embed metadata and cover to the new FLAC file
|
||||||
|
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||||
|
try {
|
||||||
|
// Update track with actual metadata from backend result (if available)
|
||||||
|
// This creates the most accurate metadata possible (from the service itself)
|
||||||
|
Track finalTrack = trackToDownload;
|
||||||
|
if (result.containsKey('track_number') || result.containsKey('release_date')) {
|
||||||
|
_log.d('Using metadata from backend response for embedding');
|
||||||
|
final backendTrackNum = result['track_number'] as int?;
|
||||||
|
final backendDiscNum = result['disc_number'] as int?;
|
||||||
|
final backendYear = result['release_date'] as String?;
|
||||||
|
final backendAlbum = result['album'] as String?;
|
||||||
|
|
||||||
|
// Create updated track object
|
||||||
|
finalTrack = Track(
|
||||||
|
id: trackToDownload.id,
|
||||||
|
name: trackToDownload.name,
|
||||||
|
artistName: trackToDownload.artistName,
|
||||||
|
albumName: backendAlbum ?? trackToDownload.albumName,
|
||||||
|
albumArtist: trackToDownload.albumArtist,
|
||||||
|
coverUrl: trackToDownload.coverUrl,
|
||||||
|
duration: trackToDownload.duration,
|
||||||
|
isrc: trackToDownload.isrc,
|
||||||
|
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber,
|
||||||
|
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber,
|
||||||
|
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
||||||
|
deezerId: trackToDownload.deezerId,
|
||||||
|
availability: trackToDownload.availability,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use enriched/updated track for metadata embedding
|
||||||
|
await _embedMetadataAndCover(flacPath, finalTrack);
|
||||||
|
_log.d('Metadata and cover embedded successfully');
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.w('FFmpeg conversion returned null, keeping M4A file');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||||
|
// Keep the M4A file if conversion fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1143,12 +1330,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
|
// Extract updated metadata from backend result if available
|
||||||
|
final backendTitle = result['title'] as String?;
|
||||||
|
final backendArtist = result['artist'] as String?;
|
||||||
|
final backendAlbum = result['album'] as String?;
|
||||||
|
final backendYear = result['release_date'] as String?;
|
||||||
|
final backendTrackNum = result['track_number'] as int?;
|
||||||
|
final backendDiscNum = result['disc_number'] as int?;
|
||||||
|
|
||||||
ref.read(downloadHistoryProvider.notifier).addToHistory(
|
ref.read(downloadHistoryProvider.notifier).addToHistory(
|
||||||
DownloadHistoryItem(
|
DownloadHistoryItem(
|
||||||
id: item.id,
|
id: item.id,
|
||||||
trackName: item.track.name,
|
trackName: (backendTitle != null && backendTitle.isNotEmpty) ? backendTitle : item.track.name,
|
||||||
artistName: item.track.artistName,
|
artistName: (backendArtist != null && backendArtist.isNotEmpty) ? backendArtist : item.track.artistName,
|
||||||
albumName: item.track.albumName,
|
albumName: (backendAlbum != null && backendAlbum.isNotEmpty) ? backendAlbum : item.track.albumName,
|
||||||
albumArtist: item.track.albumArtist,
|
albumArtist: item.track.albumArtist,
|
||||||
coverUrl: item.track.coverUrl,
|
coverUrl: item.track.coverUrl,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
@@ -1157,10 +1352,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Additional metadata
|
// Additional metadata
|
||||||
isrc: item.track.isrc,
|
isrc: item.track.isrc,
|
||||||
spotifyId: item.track.id,
|
spotifyId: item.track.id,
|
||||||
trackNumber: item.track.trackNumber,
|
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
|
||||||
discNumber: item.track.discNumber,
|
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
|
||||||
duration: item.track.duration,
|
duration: item.track.duration,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
|
||||||
quality: actualQuality,
|
quality: actualQuality,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1170,11 +1365,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
final errorMsg = result['error'] as String? ?? 'Download failed';
|
||||||
_log.e('Download failed: $errorMsg');
|
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
||||||
|
|
||||||
|
// Convert error type string to enum
|
||||||
|
DownloadErrorType errorType;
|
||||||
|
switch (errorTypeStr) {
|
||||||
|
case 'not_found':
|
||||||
|
errorType = DownloadErrorType.notFound;
|
||||||
|
break;
|
||||||
|
case 'rate_limit':
|
||||||
|
errorType = DownloadErrorType.rateLimit;
|
||||||
|
break;
|
||||||
|
case 'network':
|
||||||
|
errorType = DownloadErrorType.network;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorType = DownloadErrorType.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
|
errorType: errorType,
|
||||||
);
|
);
|
||||||
_failedInSession++;
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
@@ -1191,10 +1405,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
_log.e('Exception: $e', e, stackTrace);
|
_log.e('Exception: $e', e, stackTrace);
|
||||||
|
|
||||||
|
String errorMsg = e.toString();
|
||||||
|
DownloadErrorType errorType = DownloadErrorType.unknown;
|
||||||
|
|
||||||
|
// Check for specific Deezer fallback error
|
||||||
|
if (errorMsg.contains('could not find Deezer equivalent') ||
|
||||||
|
errorMsg.contains('track not found on Deezer')) {
|
||||||
|
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
||||||
|
errorType = DownloadErrorType.notFound;
|
||||||
|
}
|
||||||
|
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: e.toString(),
|
error: errorMsg,
|
||||||
|
errorType: errorType,
|
||||||
);
|
);
|
||||||
_failedInSession++;
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,8 +331,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
// Show percentage and speed
|
||||||
Text(
|
Text(
|
||||||
'${(item.progress * 100).toStringAsFixed(0)}%',
|
item.speedMBps > 0
|
||||||
|
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
|
||||||
|
: '${(item.progress * 100).toStringAsFixed(0)}%',
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -344,7 +347,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (item.status == DownloadStatus.failed) ...[
|
if (item.status == DownloadStatus.failed) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
item.error ?? 'Download failed',
|
item.errorMessage, // Use user-friendly error message
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
|||||||
@@ -78,6 +78,49 @@ class AboutPage extends StatelessWidget {
|
|||||||
name: AppInfo.originalAuthor,
|
name: AppInfo.originalAuthor,
|
||||||
description: 'Creator of the original SpotiFLAC',
|
description: 'Creator of the original SpotiFLAC',
|
||||||
githubUsername: AppInfo.originalAuthor,
|
githubUsername: AppInfo.originalAuthor,
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_ContributorItem(
|
||||||
|
name: 'Amonoman',
|
||||||
|
description: 'The talented artist who created our beautiful app logo!',
|
||||||
|
githubUsername: 'Amonoman',
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Special Thanks section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Special Thanks'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_ContributorItem(
|
||||||
|
name: 'uimaxbai',
|
||||||
|
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
|
||||||
|
githubUsername: 'uimaxbai',
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
_ContributorItem(
|
||||||
|
name: 'sachinsenal0x64',
|
||||||
|
description: 'The original HiFi project creator. The foundation of Tidal integration!',
|
||||||
|
githubUsername: 'sachinsenal0x64',
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.cloud_outlined,
|
||||||
|
title: 'DoubleDouble',
|
||||||
|
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
||||||
|
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||||
|
showDivider: true,
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.music_note_outlined,
|
||||||
|
title: 'DAB Music',
|
||||||
|
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
||||||
|
onTap: () => _launchUrl('https://dabmusic.xyz'),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
String? _selectedDirectory;
|
String? _selectedDirectory;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
|
|
||||||
|
// Spotify API credentials
|
||||||
|
final _clientIdController = TextEditingController();
|
||||||
|
final _clientSecretController = TextEditingController();
|
||||||
|
bool _useSpotifyApi = false;
|
||||||
|
bool _showClientSecret = false;
|
||||||
|
|
||||||
// Total steps: Storage -> Notification (Android 13+) -> Folder
|
// Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
|
||||||
int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
|
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -33,6 +39,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
_initDeviceInfo();
|
_initDeviceInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_clientIdController.dispose();
|
||||||
|
_clientSecretController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initDeviceInfo() async {
|
Future<void> _initDeviceInfo() async {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
@@ -358,6 +371,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
||||||
|
|
||||||
|
// Save Spotify credentials if provided
|
||||||
|
if (_useSpotifyApi &&
|
||||||
|
_clientIdController.text.trim().isNotEmpty &&
|
||||||
|
_clientSecretController.text.trim().isNotEmpty) {
|
||||||
|
ref.read(settingsProvider.notifier).setSpotifyCredentials(
|
||||||
|
_clientIdController.text.trim(),
|
||||||
|
_clientSecretController.text.trim(),
|
||||||
|
);
|
||||||
|
ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true);
|
||||||
|
// Set search source to Spotify when using custom credentials
|
||||||
|
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||||
|
} else {
|
||||||
|
// Use Deezer as default search source
|
||||||
|
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||||
|
}
|
||||||
|
|
||||||
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
|
ref.read(settingsProvider.notifier).setFirstLaunchComplete();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -436,8 +466,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
||||||
final steps = _androidSdkVersion >= 33
|
final steps = _androidSdkVersion >= 33
|
||||||
? ['Storage', 'Notification', 'Folder']
|
? ['Storage', 'Notification', 'Folder', 'Spotify']
|
||||||
: ['Permission', 'Folder'];
|
: ['Permission', 'Folder', 'Spotify'];
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -461,48 +491,61 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
|
||||||
final isActive = _currentStep >= step;
|
final isActive = _currentStep >= step;
|
||||||
final isCompleted = _isStepCompleted(step);
|
final isCompleted = _isStepCompleted(step);
|
||||||
|
final isCurrent = _currentStep == step;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
AnimatedContainer(
|
||||||
width: 32,
|
duration: const Duration(milliseconds: 200),
|
||||||
height: 32,
|
width: 36,
|
||||||
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: isCompleted
|
color: isCompleted
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
: isActive ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
: isCurrent
|
||||||
|
? colorScheme.primaryContainer
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
border: isCurrent && !isCompleted
|
||||||
|
? Border.all(color: colorScheme.primary, width: 2)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: isCompleted
|
child: isCompleted
|
||||||
? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
|
? Icon(Icons.check_rounded, size: 20, color: colorScheme.onPrimary)
|
||||||
: Text('${step + 1}',
|
: Text('${step + 1}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
color: isCurrent ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
fontWeight: FontWeight.bold)),
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 6),
|
||||||
Text(label,
|
Text(label,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant)),
|
color: isActive ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isStepCompleted(int step) {
|
bool _isStepCompleted(int step) {
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// 3 steps: Storage, Notification, Folder
|
// 4 steps: Storage, Notification, Folder, Spotify
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _notificationPermissionGranted;
|
case 1: return _notificationPermissionGranted;
|
||||||
case 2: return _selectedDirectory != null;
|
case 2: return _selectedDirectory != null;
|
||||||
|
case 3: return false; // Spotify step never shows checkmark (optional)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 2 steps: Permission, Folder
|
// 3 steps: Permission, Folder, Spotify
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _selectedDirectory != null;
|
case 1: return _selectedDirectory != null;
|
||||||
|
case 2: return false; // Spotify step never shows checkmark (optional)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -514,11 +557,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||||
case 1: return _buildNotificationPermissionStep(colorScheme);
|
case 1: return _buildNotificationPermissionStep(colorScheme);
|
||||||
case 2: return _buildDirectoryStep(colorScheme);
|
case 2: return _buildDirectoryStep(colorScheme);
|
||||||
|
case 3: return _buildSpotifyApiStep(colorScheme);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
case 0: return _buildStoragePermissionStep(colorScheme);
|
case 0: return _buildStoragePermissionStep(colorScheme);
|
||||||
case 1: return _buildDirectoryStep(colorScheme);
|
case 1: return _buildDirectoryStep(colorScheme);
|
||||||
|
case 2: return _buildSpotifyApiStep(colorScheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
@@ -529,35 +574,50 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// Icon with container background (M3 style)
|
||||||
_storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
|
Container(
|
||||||
size: 56,
|
width: 80,
|
||||||
color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _storagePermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_storagePermissionGranted ? Icons.check_rounded : Icons.folder_open_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _storagePermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Padding(
|
||||||
_storagePermissionGranted
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
? 'You can now proceed to the next step.'
|
child: Text(
|
||||||
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
_storagePermissionGranted
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
? 'You can now proceed to the next step.'
|
||||||
textAlign: TextAlign.center,
|
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 24),
|
||||||
if (!_storagePermissionGranted)
|
if (!_storagePermissionGranted)
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _requestStoragePermission,
|
onPressed: _isLoading ? null : _requestStoragePermission,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.security),
|
: const Icon(Icons.security_rounded),
|
||||||
label: const Text('Grant Permission'),
|
label: const Text('Grant Permission'),
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -568,39 +628,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// Icon with container background (M3 style)
|
||||||
_notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
|
Container(
|
||||||
size: 56,
|
width: 80,
|
||||||
color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _notificationPermissionGranted ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_notificationPermissionGranted ? Icons.check_rounded : Icons.notifications_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: _notificationPermissionGranted ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Padding(
|
||||||
_notificationPermissionGranted
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
? 'You will receive download progress notifications.'
|
child: Text(
|
||||||
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
_notificationPermissionGranted
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
? 'You will receive download progress notifications.'
|
||||||
textAlign: TextAlign.center,
|
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 24),
|
||||||
if (!_notificationPermissionGranted) ...[
|
if (!_notificationPermissionGranted) ...[
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _requestNotificationPermission,
|
onPressed: _isLoading ? null : _requestNotificationPermission,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.notifications_active),
|
: const Icon(Icons.notifications_active_rounded),
|
||||||
label: const Text('Enable Notifications'),
|
label: const Text('Enable Notifications'),
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _skipNotificationPermission,
|
onPressed: _skipNotificationPermission,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
),
|
||||||
child: const Text('Skip for now'),
|
child: const Text('Skip for now'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -613,51 +691,226 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
// Icon with container background (M3 style)
|
||||||
_selectedDirectory != null ? Icons.folder : Icons.create_new_folder,
|
Container(
|
||||||
size: 56,
|
width: 80,
|
||||||
color: _selectedDirectory != null ? colorScheme.primary : colorScheme.onSurfaceVariant,
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _selectedDirectory != null ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_selectedDirectory != null ? Icons.folder_rounded : Icons.create_new_folder_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _selectedDirectory != null ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (_selectedDirectory != null)
|
if (_selectedDirectory != null)
|
||||||
Container(
|
Card(
|
||||||
padding: const EdgeInsets.all(12),
|
elevation: 0,
|
||||||
decoration: BoxDecoration(
|
color: colorScheme.surfaceContainerHigh,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.folder, color: colorScheme.primary, size: 20),
|
Icon(Icons.folder_rounded, color: colorScheme.primary, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 12),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(_selectedDirectory!,
|
child: Text(
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
_selectedDirectory!,
|
||||||
overflow: TextOverflow.ellipsis),
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Text('Select a folder where your downloaded music will be saved.',
|
Padding(
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
textAlign: TextAlign.center),
|
child: Text(
|
||||||
const SizedBox(height: 20),
|
'Select a folder where your downloaded music will be saved.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isLoading ? null : _selectDirectory,
|
onPressed: _isLoading ? null : _selectDirectory,
|
||||||
icon: _isLoading
|
icon: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: Icon(_selectedDirectory != null ? Icons.edit : Icons.folder_open),
|
: Icon(_selectedDirectory != null ? Icons.edit_rounded : Icons.folder_open_rounded),
|
||||||
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
||||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSpotifyApiStep(ColorScheme colorScheme) {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Icon with container background (M3 style)
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _useSpotifyApi ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.api_rounded,
|
||||||
|
size: 40,
|
||||||
|
color: _useSpotifyApi ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Spotify API (Optional)',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
'Add your Spotify API credentials for better search results, or skip to use Deezer instead.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Toggle card (M3 style)
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: SwitchListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
title: Text('Use Spotify API', style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
subtitle: Text(
|
||||||
|
_useSpotifyApi ? 'Enter your credentials below' : 'Using Deezer (no account needed)',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
secondary: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _useSpotifyApi ? colorScheme.primary : colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_useSpotifyApi ? Icons.music_note_rounded : Icons.album_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: _useSpotifyApi ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
value: _useSpotifyApi,
|
||||||
|
onChanged: (value) => setState(() => _useSpotifyApi = value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Credentials form (animated)
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
child: _useSpotifyApi ? Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surfaceContainerHigh,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Client ID
|
||||||
|
Text('Client ID', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _clientIdController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Enter Spotify Client ID',
|
||||||
|
prefixIcon: const Icon(Icons.key_rounded),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Client Secret
|
||||||
|
Text('Client Secret', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _clientSecretController,
|
||||||
|
obscureText: !_showClientSecret,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Enter Spotify Client Secret',
|
||||||
|
prefixIcon: const Icon(Icons.lock_rounded),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
||||||
|
onPressed: () => setState(() => _showClientSecret = !_showClientSecret),
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Info banner
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.info_outline_rounded, size: 20, color: colorScheme.onTertiaryContainer),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Get credentials from developer.spotify.com',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) : const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -666,6 +919,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
Widget _buildNavigationButtons(ColorScheme colorScheme) {
|
||||||
final isLastStep = _currentStep == _totalSteps - 1;
|
final isLastStep = _currentStep == _totalSteps - 1;
|
||||||
final canProceed = _isStepCompleted(_currentStep);
|
final canProceed = _isStepCompleted(_currentStep);
|
||||||
|
|
||||||
|
// For Spotify step, check if credentials are valid when enabled
|
||||||
|
final isSpotifyStepValid = !_useSpotifyApi ||
|
||||||
|
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -674,8 +931,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (_currentStep > 0)
|
if (_currentStep > 0)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
onPressed: () => setState(() => _currentStep--),
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
label: const Text('Back'),
|
label: const Text('Back'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
const SizedBox(width: 100),
|
const SizedBox(width: 100),
|
||||||
@@ -684,20 +944,32 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
if (!isLastStep)
|
if (!isLastStep)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward, size: 18)],
|
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward_rounded, size: 18)],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _selectedDirectory != null && !_isLoading ? _completeSetup : null,
|
onPressed: isSpotifyStepValid && !_isLoading ? _completeSetup : null,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Row(
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [Text('Get Started'), SizedBox(width: 8), Icon(Icons.check, size: 18)],
|
children: [
|
||||||
|
Text(_useSpotifyApi ? 'Get Started' : 'Skip & Start'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.check_rounded, size: 18),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
final _log = AppLogger('FFmpeg');
|
||||||
@@ -133,22 +134,83 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Embed cover art to FLAC file
|
/// Embed metadata and cover art to FLAC file
|
||||||
/// Returns the file path on success, null on failure
|
/// Returns the file path on success, null on failure
|
||||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
static Future<String?> embedMetadata({
|
||||||
final tempOutput = '$flacPath.tmp';
|
required String flacPath,
|
||||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
String? coverPath,
|
||||||
|
Map<String, String>? metadata,
|
||||||
|
}) async {
|
||||||
|
// Android Scoped Storage: Cannot write directly to Music folder with FFmpeg
|
||||||
|
// Use app-internal cache directory for temp output
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
|
||||||
|
|
||||||
|
// Construct command
|
||||||
|
final StringBuffer cmdBuffer = StringBuffer();
|
||||||
|
cmdBuffer.write('-i "$flacPath" ');
|
||||||
|
|
||||||
|
// Add cover input if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-i "$coverPath" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map audio stream
|
||||||
|
cmdBuffer.write('-map 0:a ');
|
||||||
|
|
||||||
|
// Map cover stream if available
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-map 1:0 ');
|
||||||
|
cmdBuffer.write('-c:v copy ');
|
||||||
|
cmdBuffer.write('-disposition:v attached_pic ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy audio codec (don't re-encode)
|
||||||
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
|
// Add text metadata
|
||||||
|
if (metadata != null) {
|
||||||
|
metadata.forEach((key, value) {
|
||||||
|
// Sanitize value: escape double quotes
|
||||||
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
|
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('"$tempOutput" -y');
|
||||||
|
|
||||||
|
final command = cmdBuffer.toString();
|
||||||
|
_log.d('Executing FFmpeg command: $command');
|
||||||
|
|
||||||
final result = await _execute(command);
|
final result = await _execute(command);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
try {
|
try {
|
||||||
// Replace original with temp
|
// Copy temp output back to original location (replace)
|
||||||
await File(flacPath).delete();
|
final tempFile = File(tempOutput);
|
||||||
await File(tempOutput).rename(flacPath);
|
final originalFile = File(flacPath);
|
||||||
return flacPath;
|
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
// Delete original file
|
||||||
|
if (await originalFile.exists()) {
|
||||||
|
await originalFile.delete();
|
||||||
|
}
|
||||||
|
// Copy temp file to original location
|
||||||
|
await tempFile.copy(flacPath);
|
||||||
|
// Delete temp file
|
||||||
|
await tempFile.delete();
|
||||||
|
|
||||||
|
return flacPath;
|
||||||
|
} else {
|
||||||
|
_log.e('Temp output file not found: $tempOutput');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to replace file after cover embed: $e');
|
_log.e('Failed to replace file after metadata embed: $e');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +223,7 @@ class FFmpegService {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
_log.e('Cover embed failed: ${result.output}');
|
_log.e('Metadata/Cover embed failed: ${result.output}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: "none"
|
||||||
version: 2.1.5+43
|
version: 2.1.7+45
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -9,51 +9,51 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^3.1.0
|
flutter_riverpod: ^3.1.0
|
||||||
riverpod_annotation: ^4.0.0
|
riverpod_annotation: ^4.0.0
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
|
|
||||||
# Storage & Persistence
|
# Storage & Persistence
|
||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
# HTTP & Network
|
# HTTP & Network
|
||||||
http: ^1.4.0
|
http: ^1.4.0
|
||||||
dio: ^5.8.0
|
dio: ^5.8.0
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
flutter_svg: ^2.1.0
|
flutter_svg: ^2.1.0
|
||||||
|
|
||||||
# Material Expressive 3 / Dynamic Color
|
# Material Expressive 3 / Dynamic Color
|
||||||
dynamic_color: ^1.7.0
|
dynamic_color: ^1.7.0
|
||||||
material_color_utilities: ^0.11.1
|
material_color_utilities: ^0.11.1
|
||||||
|
|
||||||
# Permissions
|
# Permissions
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
|
|
||||||
# File Picker
|
# File Picker
|
||||||
file_picker: ^10.3.0
|
file_picker: ^10.3.0
|
||||||
|
|
||||||
# JSON Serialization
|
# JSON Serialization
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
device_info_plus: ^12.3.0
|
device_info_plus: ^12.3.0
|
||||||
share_plus: ^12.0.1
|
share_plus: ^12.0.1
|
||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
logger: ^2.5.0
|
logger: ^2.5.0
|
||||||
|
|
||||||
# FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
|
# FFmpeg - using local custom AAR (arm64-v8a + armeabi-v7a only)
|
||||||
# ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
|
# ffmpeg_kit_flutter_new_audio: ^2.0.0 # Replaced with local AAR
|
||||||
open_filex: ^4.7.0
|
open_filex: ^4.7.0
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^19.0.0
|
flutter_local_notifications: ^19.0.0
|
||||||
|
|
||||||
@@ -77,6 +77,6 @@ flutter_launcher_icons:
|
|||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: "none"
|
||||||
version: 2.1.0-preview2+40
|
version: 2.1.7+45
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
@@ -9,51 +9,51 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
flutter_riverpod: ^3.1.0
|
flutter_riverpod: ^3.1.0
|
||||||
riverpod_annotation: ^4.0.0
|
riverpod_annotation: ^4.0.0
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
|
|
||||||
# Storage & Persistence
|
# Storage & Persistence
|
||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
# HTTP & Network
|
# HTTP & Network
|
||||||
http: ^1.4.0
|
http: ^1.4.0
|
||||||
dio: ^5.8.0
|
dio: ^5.8.0
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
flutter_svg: ^2.1.0
|
flutter_svg: ^2.1.0
|
||||||
|
|
||||||
# Material Expressive 3 / Dynamic Color
|
# Material Expressive 3 / Dynamic Color
|
||||||
dynamic_color: ^1.7.0
|
dynamic_color: ^1.7.0
|
||||||
material_color_utilities: ^0.11.1
|
material_color_utilities: ^0.11.1
|
||||||
|
|
||||||
# Permissions
|
# Permissions
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
|
|
||||||
# File Picker
|
# File Picker
|
||||||
file_picker: ^10.3.0
|
file_picker: ^10.3.0
|
||||||
|
|
||||||
# JSON Serialization
|
# JSON Serialization
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
device_info_plus: ^12.3.0
|
device_info_plus: ^12.3.0
|
||||||
share_plus: ^12.0.1
|
share_plus: ^12.0.1
|
||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
logger: ^2.5.0
|
logger: ^2.5.0
|
||||||
|
|
||||||
# FFmpeg for iOS (uses plugin, Android uses custom AAR)
|
# FFmpeg for iOS (uses plugin, Android uses custom AAR)
|
||||||
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
ffmpeg_kit_flutter_new_audio: ^2.0.0
|
||||||
open_filex: ^4.7.0
|
open_filex: ^4.7.0
|
||||||
|
|
||||||
# Notifications
|
# Notifications
|
||||||
flutter_local_notifications: ^19.0.0
|
flutter_local_notifications: ^19.0.0
|
||||||
|
|
||||||
@@ -77,6 +77,6 @@ flutter_launcher_icons:
|
|||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
|||||||