diff --git a/CHANGELOG.md b/CHANGELOG.md
index 200521a2..bbe377d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,18 +1,94 @@
# Changelog
-## [2.1.5] - 2026-01-08
+## [2.1.6] - 2026-01-08
### Added
+
+- **Metadata Enrichment**: Automatically fetches full track details if metadata is incomplete (e.g., Track Number 0)
+ - Fixes missing Track Number, Disc Number, and Year for tracks added from Search results
+ - Ensures accurate tagging for Deezer/Tidal downloads
+- **ISRC Index Building**: Fast duplicate checking with cached ISRC index
+
+ - Scans download folder once and builds index of all ISRCs
+ - 5 minute cache TTL for optimal performance
+ - Parallel duplicate checking for album/playlist tracks
+ - Auto-adds new downloads to index (no rebuild needed)
+
+- **Japanese to Romaji Search**: Better search results for Japanese tracks
+
+ - Converts Hiragana/Katakana to Romaji for Tidal/Qobuz search
+ - 4 fallback search strategies (like PC version):
+ 1. Original text (artist + track)
+ 2. Romaji converted (artist + track)
+ 3. ASCII-only cleaned version
+ 4. Artist name only as last resort
+ - Handles combination characters (きゃ →kya, シャ →sha, etc.)
+
+- **SongLink Deezer Support**: Query SongLink using Deezer ID as source
+
+ - `CheckAvailabilityFromDeezer()` - find track on other platforms using Deezer ID
+ - `CheckAvailabilityByPlatform()` - generic function for any platform
+ - `GetSpotifyIDFromDeezer()`, `GetTidalURLFromDeezer()`, `GetAmazonURLFromDeezer()`
+ - Useful when starting from Deezer metadata
+
+- **LRC Metadata Headers**: Lyrics now include metadata headers
+
+ - `[ti:Track Name]` - track title
+ - `[ar:Artist Name]` - artist name
+ - `[by:SpotiFLAC-Mobile]` - generator tag
+
+- **Download Error Types**: Better error categorization for UI
+
+ - `not_found` - track not available on any service
+ - `rate_limit` - API rate limit exceeded
+ - `network` - connection/timeout errors
+ - `unknown` - other errors
+
+- **Amazon Rate Limiting**: Proper rate limiting for Amazon via SongLink
+ - 7 second minimum delay between requests
+ - Max 9 requests per minute
+ - 3x retry with 15s wait on 429 rate limit
+
+### Fixed
+
+- **SongLink 400 Error**: Added validation for empty Spotify ID
+
+ - Specific error messages for 400, 404, 429 status codes
+ - Better error handling for invalid track IDs
+
+- **gomobile Compatibility**: Fixed `ISRCIndex.Lookup()` signature
+ - Changed from `(string, bool)` to `(string, error)` for gomobile binding
+
+### Technical
+
+- New file: `go_backend/romaji.go` with Japanese to Romaji conversion
+- New file: `go_backend/duplicate.go` with ISRC index building
+- Updated `go_backend/tidal.go` and `go_backend/qobuz.go` with romaji search strategies
+- Updated `go_backend/songlink.go` with Deezer support functions
+- Updated `go_backend/exports.go` with new export functions for Flutter
+- Updated `go_backend/lyrics.go` with `convertToLRCWithMetadata()`
+- Updated `go_backend/progress.go` with `SpeedMBps` field
+- Updated `lib/models/download_item.dart` with `DownloadErrorType` enum
+- Updated `lib/screens/queue_tab.dart` with speed display and error messages
+
+---
+
+## [2.1.6-preview] - 2026-01-08
+
+### Added
+
- **Deezer as Alternative Metadata Source**: Choose between Deezer or Spotify for search
+
- Configure in Settings > Options > Spotify API > Search Source
- Default is Deezer for better reliability
- Spotify URLs are always supported regardless of this setting
+
- **Automatic Deezer Fallback for Spotify URLs**: When Spotify API is rate limited (429), automatically falls back to Deezer
- Uses SongLink/Odesli API to convert Spotify track/album ID to Deezer ID
- Fetches metadata from Deezer instead
- - Works for tracks and albums (playlists are user-specific, artists require Spotify API)
### Changed
+
- **Default Download Service**: Changed from Tidal to Qobuz
- Fallback order is now: Qobuz → Tidal → Amazon
- **Deezer API Updated to v2.0**: More reliable and complete metadata
@@ -20,6 +96,7 @@
- Search results now fetch full track info to include ISRC
### Fixed
+
- **Progress Bar Not Updating**: Fixed bug where download progress jumped from 1% directly to 100%
- Progress now updates smoothly every 64KB of data received
- First progress update happens immediately when download starts
@@ -28,18 +105,17 @@
- Incomplete files are automatically deleted and error is reported
- Applies to all services: Tidal, Qobuz, and Amazon
- **ISRC Not Available from Deezer Search**: Search results now fetch full track details to get ISRC
- - Improves track matching accuracy when downloading
### 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
+
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- Service selector chips appear above quality options
- Defaults to your preferred service from settings
@@ -55,6 +131,7 @@
- Configure in Settings > Options > App
### Changed
+
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- arm64 APK: 46.6 MB (previously 51 MB)
- arm32 APK: 59 MB (previously 64 MB)
@@ -64,6 +141,7 @@
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
### Fixed
+
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- Now properly handles retry when queue processing has finished
- Also allows retrying skipped (cancelled) downloads
@@ -75,6 +153,7 @@
- Files saved to app Documents folder are accessible via iOS Files app
### Performance
+
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- Token caching for Tidal (eliminates redundant auth requests)
- Singleton pattern for all downloaders (HTTP connection reuse)
@@ -90,6 +169,7 @@
## [2.1.0-preview2] - 2026-01-06
### Added
+
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- Service selector chips appear above quality options
- Defaults to your preferred service from settings
@@ -105,6 +185,7 @@
- Configure in Settings > Options > App
### Fixed
+
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- Now properly handles retry when queue processing has finished
- Also allows retrying skipped (cancelled) downloads
@@ -116,6 +197,7 @@
## [2.1.0-preview] - 2026-01-06
### Performance
+
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- Token caching for Tidal (eliminates redundant auth requests)
- Singleton pattern for all downloaders (HTTP connection reuse)
@@ -129,6 +211,7 @@
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
### Technical
+
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
@@ -136,6 +219,7 @@
## [2.0.7-preview2] - 2026-01-06
### Fixed
+
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
- iOS limitation: Empty folders cannot be selected via document picker
- Added "App Documents Folder" option as recommended default
@@ -145,6 +229,7 @@
## [2.0.7-preview] - 2026-01-05
### Changed
+
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- arm64 APK: 46.6 MB (previously 51 MB)
- arm32 APK: 59 MB (previously 64 MB)
@@ -152,6 +237,7 @@
- Removed x86/x86_64 architectures (emulator only)
### Technical
+
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
- Native MethodChannel bridge for FFmpeg operations
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
@@ -159,6 +245,7 @@
## [2.0.6] - 2026-01-05
### Fixed
+
- **Duration Display Bug**: Fixed duration showing incorrect values like "4135:53" instead of "4:14"
- `duration_ms` (milliseconds) was being stored directly without conversion to seconds
- Now properly converts milliseconds to seconds before display
@@ -178,14 +265,17 @@
## [2.0.5] - 2026-01-05
### Added
+
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
### Fixed
+
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
## [2.0.4] - 2026-01-04
### Fixed
+
- **Android 11 Storage Permission**: Fixed "Permission denied" error on Android 11 (API 30) devices
- Added `MANAGE_EXTERNAL_STORAGE` permission for Android 11-12
- Shows explanation dialog before opening system settings
@@ -193,6 +283,7 @@
## [2.0.3] - 2026-01-03
### Added
+
- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting
- Toggle to enable/disable custom credentials without deleting them
- Material Expressive 3 bottom sheet UI for entering credentials
@@ -200,9 +291,11 @@
- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens
### Changed
+
- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls)
### Fixed
+
- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted.
- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search
- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search
@@ -212,6 +305,7 @@
## [2.0.2] - 2026-01-03
### Added
+
- **Actual Quality Display**: Shows real audio quality (bit depth/sample rate) after download
- Quality badge on download history items (e.g., "24-bit", "16-bit")
- Full quality info in Track Metadata screen (e.g., "24-bit/96kHz")
@@ -220,13 +314,16 @@
- **Instant Lyrics Loading**: Lyrics now load from embedded file first (instant) before falling back to internet fetch
### Fixed
+
- **Fallback Service Display**: Fixed download history showing wrong service when fallback occurs (e.g., showing "TIDAL" when actually downloaded from "QOBUZ")
- **Open in Spotify**: Fixed "Open in Spotify" button not opening Spotify app correctly
### Removed
+
- **Romaji Conversion**: Removed Japanese lyrics to romaji conversion feature (Kanji not supported, results were incomplete)
### Technical
+
- Go backend now returns `actual_bit_depth` and `actual_sample_rate` in download response
- Go backend now returns `service` field indicating actual service used (important for fallback)
- Tidal API v2 response provides exact quality info
@@ -236,18 +333,21 @@
## [2.0.1] - 2026-01-03
### Added
+
- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker
- Tap to expand long track titles
- Expand icon only shows when title is truncated
- Ripple effect follows rounded corners including drag handle
### Changed
+
- **Unified Progress Tracking System**: Deprecated legacy single-download progress
- All downloads now use item-based progress tracking
- Fixes duplicate notification bug when finalizing
- Cleaner codebase with single progress system
### Fixed
+
- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously
- **Update Notification Stuck**: Fixed notification staying at 100% after download completes
- **Quality Picker Consistency**: Unified quality picker UI across all screens (Home, Album, Playlist)
@@ -257,6 +357,7 @@
## [2.0.0] - 2026-01-03
### Added
+
- **Artist Search Results**: Search now shows artists alongside tracks
- Horizontal scrollable artist cards with circular avatars
- Tap artist to view their discography
@@ -277,11 +378,12 @@
- Stable users won't receive update notifications for preview versions
### Changed
+
- **Instant Navigation UX**: Navigate to Artist/Album screens immediately
- Header (name, cover) shows instantly from available data
- Content (albums/tracks) loads in background inside the screen
- Second visit to same artist/album is instant from Flutter cache
-- **Search Results UI Redesign**:
+- **Search Results UI Redesign**:
- Removed "Download All" button from search results
- Added "Songs" section header (matches "Artists" header style)
- Track list now in grouped card with rounded corners (like Settings)
@@ -304,6 +406,7 @@
- **Ask Before Download Default**: Now enabled by default for better UX
### Fixed
+
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
- **Album Card Overflow**: Fixed 5px overflow in artist discography album cards
- **Optimized Rebuilds**: Each track item only rebuilds when its own status changes
@@ -314,6 +417,7 @@
## [1.6.3] - 2026-01-03
### Added
+
- **Predictive Back Navigation**: Support for Android 14+ predictive back gesture with smooth animations
- **Separate Detail Screens**: Album, Artist, and Playlist now open as dedicated screens with Material Expressive 3 design
- Collapsing header with cover art and gradient overlay
@@ -323,6 +427,7 @@
- **Double-Tap to Exit**: Press back twice to exit app when at home screen (replaces exit dialog)
### Changed
+
- **Navigation Architecture**: Refactored from state-based to screen-based navigation
- Album/Artist/Playlist URLs navigate to dedicated screens via `Navigator.push()`
- Enables native predictive back gesture animations
@@ -332,17 +437,21 @@
## [1.6.2] - 2026-01-02
### Added
+
- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
### Changed
+
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
### Fixed
+
- **Play Button Flash**: Fixed play button briefly showing red error icon on app start (now uses optimistic rendering)
### Performance
+
- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
- **List Keys**: Added keys to all list builders for efficient list updates and reordering
- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
@@ -354,12 +463,14 @@
## [1.6.1] - 2026-01-02
### Added
+
- **Background Download Service**: Downloads now continue running when app is in background
- Foreground service with wake lock prevents Android from killing downloads
- Persistent notification shows download progress
- No more "connection abort" errors when switching apps
### Fixed
+
- **Share Intent App Restart**: Fixed download queue being lost when sharing from Spotify while downloads are in progress
- Download queue is now persisted to storage and automatically restored on app restart
- Interrupted downloads (marked as "downloading") are reset to "queued" and auto-resumed
@@ -368,11 +479,13 @@
- **Back Button During Loading**: Back button no longer clears state while loading shared URL
### Changed
+
- **Kotlin**: Upgraded from 2.2.20 to 2.3.0 for better plugin compatibility
## [1.6.0] - 2026-01-02
### Added
+
- **Manual Quality Selection**: New option to choose audio quality before each download
- Toggle "Ask Before Download" in Download Settings
- When enabled, shows quality picker (Lossless, Hi-Res, Hi-Res Max) before downloading
@@ -387,12 +500,14 @@
- **Share Audio File**: Share downloaded tracks to other apps from Track Metadata screen
### Fixed
+
- **Update Checker**: Fixed version comparison for versions with suffix (e.g., `1.5.0-hotfix6`)
- Users on hotfix versions now properly receive update notifications
- Handles `-hotfix`, `-beta`, `-rc` suffixes correctly
- **Settings Ripple Effect**: Fixed splash/ripple effect to properly clip within rounded card corners
### Changed
+
- **Settings UI Redesign**: New Android-style grouped settings with connected cards
- Items in same group are connected with rounded card container
- Section headers outside cards for clear visual hierarchy
@@ -401,6 +516,7 @@
- **Consistent Header Position**: Fixed Search tab header alignment to match History and Settings tabs
### Improved
+
- **Code Quality**: Replaced all `print()` statements with structured logging using `logger` package
- **Dependencies Updated**:
- `share_plus`: 10.1.4 → 12.0.1
@@ -410,6 +526,7 @@
## [1.5.5] - 2026-01-02
### Added
+
- **Share to App**: Share Spotify links directly from Spotify app or browser to SpotiFLAC
- Supports track, album, playlist, and artist URLs
- Auto-fetches metadata when link is shared
@@ -436,6 +553,7 @@
- **Exit Confirmation**: Dialog prompt when pressing back to exit app (only at root)
### Changed
+
- **Downloads Tab Renamed to History**: Better reflects the tab's purpose
- Shows download queue at top when active
- Completed downloads auto-move to history section
@@ -446,11 +564,13 @@
- Only shows exit dialog when truly at root
### Fixed
+
- **Download Progress**: Fixed progress stuck at 0% when using item-based progress tracking (affected sequential downloads after multi-download feature was added)
- **Artist View State**: Fixed UI state not clearing properly when switching between artist and album views
- **Share Intent Timing**: Fixed shared URLs not being processed when app was cold-started from share intent
### Improved
+
- **Cleaner UI for Returning Users**: Helper text "Supports: Track, Album, Playlist URLs" now only shows for new users and hides after first search
- **Cleaner Home Tab**: Removed redundant "Recent Downloads" section, renamed to "Search" tab
- **Centered Search Bar**: Search bar now appears centered on screen when empty, moves to top when results are shown - easier to reach on large phones
@@ -459,26 +579,31 @@
## [1.5.0-hotfix6] - 2026-01-02
### Fixed
+
- **App Signing**: Use r0adkll/sign-android-release GitHub Action for reliable signing
## [1.5.0-hotfix5] - 2026-01-02
### Fixed
+
- **App Signing**: Use key.properties as per Flutter official documentation
## [1.5.0-hotfix4] - 2026-01-02
### Fixed
+
- **App Signing**: Create keystore.properties in workflow for Gradle
## [1.5.0-hotfix] - 2026-01-02
### Important Notice
+
We apologize for the inconvenience. Previous releases were signed with different keys, causing "package conflicts" errors when upgrading. Starting from this version, all releases will use a consistent signing key.
**If you're upgrading from v1.5.0 or earlier, please uninstall the app first before installing this version.** This is a one-time requirement. Future updates will work seamlessly without uninstalling.
### Added
+
- **In-App Update**: Download and install updates directly from the app
- Progress bar shows download status
- Automatic device architecture detection (arm64/arm32)
@@ -486,11 +611,13 @@ We apologize for the inconvenience. Previous releases were signed with different
- **Consistent App Signing**: All future releases will use the same signing key
### Fixed
+
- **Update Checker**: Now downloads APK directly instead of opening browser
## [1.5.0] - 2026-01-02
### Added
+
- **Download Progress Notification**: Shows notification with download progress percentage while downloading
- Progress bar in notification during download
- Completion notification when track finishes
@@ -514,6 +641,7 @@ We apologize for the inconvenience. Previous releases were signed with different
- Downloads correct APK for your device
### Changed
+
- **Recent Downloads**: Now shows up to 10 items (was 5) for better scrolling
- **Queue UI Redesign**: Card-based layout with clearer status indicators
- Removed global pause/resume in favor of per-item controls
@@ -538,6 +666,7 @@ We apologize for the inconvenience. Previous releases were signed with different
- "Add Music" button for quick access
### Technical
+
- Added `flutter_local_notifications` package for notifications
- Added notification permission request in setup screen for Android 13+
- Enabled core library desugaring for all Android subprojects
@@ -546,6 +675,7 @@ We apologize for the inconvenience. Previous releases were signed with different
- Updated platform channel handlers for both Android (Kotlin) and iOS (Swift)
### Performance
+
- Optimized SliverAppBar: Removed LayoutBuilder that was called every frame during scroll
- Optimized image caching: Added `memCacheWidth/Height` to CachedNetworkImage for memory efficiency
- Optimized state management: Use `select()` to only rebuild when specific state changes
@@ -554,6 +684,7 @@ We apologize for the inconvenience. Previous releases were signed with different
## [1.2.0] - 2026-01-02
### Added
+
- **Track Metadata Screen**: New detailed metadata view when tapping on downloaded tracks
- Material Expressive 3 design with cover art header and gradient
- Hero animation from list to detail view
@@ -566,12 +697,14 @@ We apologize for the inconvenience. Previous releases were signed with different
- **Hi-Res Lossless MAX**: New highest quality option for maximum audio fidelity
### Fixed
+
- **Hi-Res Quality Bug**: Fixed issue where Hi-Res downloads were stuck at Lossless quality
- Users on previous versions are recommended to upgrade to get proper Hi-Res downloads
- **Settings Navigation Bug**: Fixed issue where changing settings (like audio quality) would navigate back to Home tab
- **Tidal Badge Color**: Fixed unreadable Tidal service badge (was too bright cyan, now darker blue)
### Changed
+
- **Recent Downloads**: Tapping on a track now opens metadata screen instead of playing directly
- Play button still available for quick playback
- **Download History Model**: Extended with additional metadata fields (albumArtist, isrc, spotifyId, trackNumber, discNumber, duration, releaseDate, quality)
@@ -580,30 +713,34 @@ We apologize for the inconvenience. Previous releases were signed with different
## [1.1.2] - 2026-01-01
### Added
+
- **Update Checker**: Automatic check for new versions from GitHub releases
- Shows changelog in update dialog
- Option to disable update notifications
- **Release Changelog**: GitHub releases now include full changelog
### Changed
+
- Updated version to 1.1.2
## [1.1.1] - 2026-01-01
### Fixed
+
- **About Dialog**: Custom About dialog with cleaner layout
- **Setup Screen**: Fixed step indicator line alignment
- **Warning Text**: Fixed parallel downloads warning to use Material theme colors
- **Copyright Year**: Updated to 2026
### Changed
+
- Removed Theme Preview from Settings
- Added MIT License
-
## [1.1.0] - 2026-01-01
### Added
+
- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings)
- Default: Sequential (1 at a time) for stability
- Options: 1, 2, or 3 concurrent downloads
@@ -614,15 +751,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
### Fixed
+
- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads
- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup
- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces
- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug`
### Changed
+
- Updated version to 1.1.0
### Technical Details
+
- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3)
- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing
- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend
@@ -631,6 +771,7 @@ We apologize for the inconvenience. Previous releases were signed with different
- Added `CleanupConnections()` export for Flutter to call via method channel
## [1.0.5] - Previous Release
+
- Material Expressive 3 UI
- Dynamic color support
- Swipe navigation with PageView
diff --git a/android/app/build.gradle b/android/app/build.gradle.bak
similarity index 100%
rename from android/app/build.gradle
rename to android/app/build.gradle.bak
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 871c5c4d..d04ce688 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,3 +1,6 @@
+import java.util.Properties
+import java.io.FileInputStream
+
plugins {
id("com.android.application")
id("kotlin-android")
@@ -7,9 +10,9 @@ plugins {
// Load keystore properties for local builds
val keystorePropertiesFile = rootProject.file("key.properties")
-val keystoreProperties = java.util.Properties()
+val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
- keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))
+ keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
@@ -32,10 +35,10 @@ android {
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
- keyAlias = keystoreProperties["keyAlias"] as String
- keyPassword = keystoreProperties["keyPassword"] as String
- storeFile = file(keystoreProperties["storeFile"] as String)
- storePassword = keystoreProperties["storePassword"] as String
+ keyAlias = keystoreProperties.getProperty("keyAlias")
+ keyPassword = keystoreProperties.getProperty("keyPassword")
+ storeFile = file(keystoreProperties.getProperty("storeFile"))
+ storePassword = keystoreProperties.getProperty("storePassword")
}
}
}
@@ -94,8 +97,10 @@ repositories {
dependencies {
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("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
}
diff --git a/android/app/libs/smart-exception-common-0.2.1.jar b/android/app/libs/smart-exception-common-0.2.1.jar
new file mode 100644
index 00000000..624d64d7
Binary files /dev/null and b/android/app/libs/smart-exception-common-0.2.1.jar differ
diff --git a/android/app/libs/smart-exception-java-0.2.1.jar b/android/app/libs/smart-exception-java-0.2.1.jar
new file mode 100644
index 00000000..2a16b315
Binary files /dev/null and b/android/app/libs/smart-exception-java-0.2.1.jar differ
diff --git a/go_backend/amazon.go b/go_backend/amazon.go
index 4455a9ca..06def6f4 100644
--- a/go_backend/amazon.go
+++ b/go_backend/amazon.go
@@ -17,14 +17,18 @@ import (
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
type AmazonDownloader struct {
- client *http.Client
- regions []string // us, eu regions for DoubleDouble service
+ client *http.Client
+ regions []string // us, eu regions for DoubleDouble service
+ lastAPICallTime time.Time // Rate limiting: track last API call
+ apiCallCount int // Rate limiting: counter per minute
+ apiCallResetTime time.Time // Rate limiting: reset time
}
var (
// Global Amazon downloader instance for connection reuse
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
+ amazonRateLimitMu sync.Mutex // Mutex for rate limiting
)
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
@@ -105,13 +109,55 @@ func amazonIsASCIIString(s string) bool {
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
- client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
- regions: []string{"us", "eu"}, // Same regions as PC
+ client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
+ regions: []string{"us", "eu"}, // Same regions as PC
+ apiCallResetTime: time.Now(),
}
})
return globalAmazonDownloader
}
+// waitForRateLimit implements rate limiting similar to PC version
+// Max 9 requests per minute with 7 second delay between requests
+func (a *AmazonDownloader) waitForRateLimit() {
+ amazonRateLimitMu.Lock()
+ defer amazonRateLimitMu.Unlock()
+
+ now := time.Now()
+
+ // Reset counter every minute
+ if now.Sub(a.apiCallResetTime) >= time.Minute {
+ a.apiCallCount = 0
+ a.apiCallResetTime = now
+ }
+
+ // If we've hit the limit (9 requests per minute), wait until next minute
+ if a.apiCallCount >= 9 {
+ waitTime := time.Minute - now.Sub(a.apiCallResetTime)
+ if waitTime > 0 {
+ fmt.Printf("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
+ time.Sleep(waitTime)
+ a.apiCallCount = 0
+ a.apiCallResetTime = time.Now()
+ }
+ }
+
+ // Add delay between requests (7 seconds like PC version)
+ if !a.lastAPICallTime.IsZero() {
+ timeSinceLastCall := now.Sub(a.lastAPICallTime)
+ minDelay := 7 * time.Second
+ if timeSinceLastCall < minDelay {
+ waitTime := minDelay - timeSinceLastCall
+ fmt.Printf("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
+ time.Sleep(waitTime)
+ }
+ }
+
+ // Update tracking
+ a.lastAPICallTime = time.Now()
+ a.apiCallCount++
+}
+
// GetAvailableAPIs returns list of available DoubleDouble regions
// Uses same service as PC version (doubledouble.top)
func (a *AmazonDownloader) GetAvailableAPIs() []string {
@@ -140,10 +186,13 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
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)
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
+ // Apply rate limiting before request (like PC version)
+ a.waitForRateLimit()
+
req, err := http.NewRequest("GET", submitURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create request: %w", err)
@@ -153,15 +202,43 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
req.Header.Set("User-Agent", getRandomUserAgent())
fmt.Println("[Amazon] Submitting download request...")
- resp, err := a.client.Do(req)
- if err != nil {
- lastError = fmt.Errorf("failed to submit request: %w", err)
- continue
+
+ // Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
+ var resp *http.Response
+ maxRetries := 3
+ for retry := 0; retry < maxRetries; retry++ {
+ resp, err = a.client.Do(req)
+ if err != nil {
+ lastError = fmt.Errorf("failed to submit request: %w", err)
+ break
+ }
+
+ if resp.StatusCode == 429 { // Too Many Requests
+ resp.Body.Close()
+ if retry < maxRetries-1 {
+ waitTime := 15 * time.Second
+ 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 {
- resp.Body.Close()
- lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
+ if err != nil || lastError != nil {
+ if resp != nil {
+ resp.Body.Close()
+ }
continue
}
@@ -348,9 +425,15 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
// AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct {
- FilePath string
- BitDepth int
- SampleRate int
+ FilePath string
+ BitDepth int
+ SampleRate int
+ Title string
+ Artist string
+ Album string
+ ReleaseDate string
+ TrackNumber int
+ DiscNumber int
}
// downloadFromAmazon downloads a track using the request parameters
@@ -365,7 +448,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
// Get Amazon URL from SongLink
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 {
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)
if err != nil {
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
+ // Add to ISRC index for fast duplicate checking
+ AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
// Return 0 to indicate unknown quality
return AmazonDownloadResult{
FilePath: outputPath,
@@ -500,9 +600,19 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
+
+ // Add to ISRC index for fast duplicate checking
+ AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
+
return AmazonDownloadResult{
- FilePath: outputPath,
- BitDepth: quality.BitDepth,
- SampleRate: quality.SampleRate,
+ FilePath: outputPath,
+ BitDepth: quality.BitDepth,
+ SampleRate: quality.SampleRate,
+ Title: req.TrackName,
+ Artist: req.ArtistName,
+ Album: req.AlbumName,
+ ReleaseDate: req.ReleaseDate,
+ TrackNumber: req.TrackNumber,
+ DiscNumber: req.DiscNumber,
}, nil
}
diff --git a/go_backend/deezer.go b/go_backend/deezer.go
index dc511489..e6b4e538 100644
--- a/go_backend/deezer.go
+++ b/go_backend/deezer.go
@@ -21,6 +21,9 @@ const (
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
deezerCacheTTL = 10 * time.Minute
+
+ // Parallel ISRC fetching settings
+ deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
)
// DeezerClient handles Deezer API interactions (no auth required)
@@ -29,6 +32,7 @@ type DeezerClient struct {
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
+ isrcCache map[string]string // trackID -> ISRC cache
cacheMu sync.RWMutex
}
@@ -46,6 +50,7 @@ func GetDeezerClient() *DeezerClient {
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry),
+ isrcCache: make(map[string]string),
}
})
return deezerClient
@@ -60,6 +65,7 @@ type deezerTrack struct {
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
Link string `json:"link"`
+ ReleaseDate string `json:"release_date"` // Sometimes at track level
Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"`
@@ -82,6 +88,52 @@ type deezerAlbumSimple struct {
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"`
+ ReleaseDate string `json:"release_date"` // Sometimes at album level
+}
+// ... (skip other structs as they are fine/unchanged) ...
+
+// ... (in convertTrack) ...
+func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
+ artistName := track.Artist.Name
+ if len(track.Contributors) > 0 {
+ names := make([]string, len(track.Contributors))
+ for i, a := range track.Contributors {
+ names[i] = a.Name
+ }
+ artistName = strings.Join(names, ", ")
+ }
+
+ albumImage := track.Album.CoverXL
+ if albumImage == "" {
+ albumImage = track.Album.CoverBig
+ }
+ if albumImage == "" {
+ albumImage = track.Album.CoverMedium
+ }
+ if albumImage == "" {
+ albumImage = track.Album.Cover
+ }
+
+ // Try to find release date
+ releaseDate := track.ReleaseDate
+ if releaseDate == "" {
+ releaseDate = track.Album.ReleaseDate
+ }
+
+ return TrackMetadata{
+ SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
+ Artists: artistName,
+ Name: track.Title,
+ AlbumName: track.Album.Title,
+ AlbumArtist: track.Artist.Name,
+ DurationMS: track.Duration * 1000,
+ Images: albumImage,
+ ReleaseDate: releaseDate, // Added this
+ TrackNumber: track.TrackPosition,
+ DiscNumber: track.DiskNumber,
+ ExternalURL: track.Link,
+ ISRC: track.ISRC,
+ }
}
type deezerAlbumFull struct {
@@ -128,6 +180,7 @@ type deezerPlaylistFull struct {
}
// SearchAll searches for tracks and artists on Deezer
+// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
@@ -143,7 +196,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
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)
var trackResp struct {
Data []deezerTrack `json:"data"`
@@ -153,14 +206,8 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
}
for _, track := range trackResp.Data {
- // Fetch full track info to get ISRC (search results don't include ISRC)
- fullTrack, err := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
- 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))
- }
+ // Convert directly without fetching ISRC - much faster
+ result.Tracks = append(result.Tracks, c.convertTrack(track))
}
// Search artists
@@ -206,6 +253,7 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
}
// GetAlbum fetches album with tracks
+// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
c.cacheMu.RLock()
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
@@ -239,14 +287,13 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
Images: albumImage,
}
+ // Fetch ISRCs in parallel
+ isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
+
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
for _, track := range album.Tracks.Data {
- // Need to fetch full track info for ISRC
- fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
- isrc := ""
- if fullTrack != nil {
- isrc = fullTrack.ISRC
- }
+ trackIDStr := fmt.Sprintf("%d", track.ID)
+ isrc := isrcMap[trackIDStr]
tracks = append(tracks, AlbumTrackMetadata{
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
+// ISRC is fetched in parallel for better performance
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
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.Images = playlistImage
+ // Fetch ISRCs in parallel
+ isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
+
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
for _, track := range playlist.Tracks.Data {
albumImage := track.Album.CoverXL
@@ -400,13 +451,8 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
albumImage = track.Album.CoverMedium
}
- // Fetch full track for ISRC
- fullTrack, _ := c.fetchFullTrack(ctx, fmt.Sprintf("%d", track.ID))
- isrc := ""
- releaseDate := ""
- if fullTrack != nil {
- isrc = fullTrack.ISRC
- }
+ trackIDStr := fmt.Sprintf("%d", track.ID)
+ isrc := isrcMap[trackIDStr]
tracks = append(tracks, AlbumTrackMetadata{
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,
DurationMS: track.Duration * 1000,
Images: albumImage,
- ReleaseDate: releaseDate,
+ ReleaseDate: "",
TrackNumber: track.TrackPosition,
DiscNumber: track.DiskNumber,
ExternalURL: track.Link,
@@ -472,42 +518,93 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
return &track, nil
}
-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
+// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching
+func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
+ result := make(map[string]string)
+ var resultMu sync.Mutex
+
+ // First, check cache for existing ISRCs
+ var tracksToFetch []deezerTrack
+ c.cacheMu.RLock()
+ for _, track := range tracks {
+ trackIDStr := fmt.Sprintf("%d", track.ID)
+ if isrc, ok := c.isrcCache[trackIDStr]; ok {
+ result[trackIDStr] = isrc
+ } else {
+ tracksToFetch = append(tracksToFetch, track)
}
- artistName = strings.Join(names, ", ")
}
-
- albumImage := track.Album.CoverXL
- if albumImage == "" {
- albumImage = track.Album.CoverBig
+ c.cacheMu.RUnlock()
+
+ if len(tracksToFetch) == 0 {
+ return result
}
- if albumImage == "" {
- albumImage = track.Album.CoverMedium
- }
- if albumImage == "" {
- albumImage = track.Album.Cover
- }
-
- 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,
- TrackNumber: track.TrackPosition,
- DiscNumber: track.DiskNumber,
- ExternalURL: track.Link,
- ISRC: track.ISRC,
+
+ // Use semaphore to limit concurrent requests
+ sem := make(chan struct{}, deezerMaxParallelISRC)
+ var wg sync.WaitGroup
+
+ for _, track := range tracksToFetch {
+ wg.Add(1)
+ go func(t deezerTrack) {
+ defer wg.Done()
+
+ // Acquire semaphore
+ select {
+ case sem <- struct{}{}:
+ defer func() { <-sem }()
+ case <-ctx.Done():
+ return
+ }
+
+ trackIDStr := fmt.Sprintf("%d", t.ID)
+ fullTrack, err := c.fetchFullTrack(ctx, trackIDStr)
+ if err != nil || fullTrack == nil {
+ return
+ }
+
+ // Store in result and cache
+ resultMu.Lock()
+ result[trackIDStr] = fullTrack.ISRC
+ resultMu.Unlock()
+
+ c.cacheMu.Lock()
+ c.isrcCache[trackIDStr] = fullTrack.ISRC
+ c.cacheMu.Unlock()
+ }(track)
}
+
+ wg.Wait()
+ return result
}
+// GetTrackISRC fetches ISRC for a single track (with caching)
+// Use this when you need ISRC for download
+func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
+ // Check cache first
+ c.cacheMu.RLock()
+ if isrc, ok := c.isrcCache[trackID]; ok {
+ c.cacheMu.RUnlock()
+ return isrc, nil
+ }
+ c.cacheMu.RUnlock()
+
+ // Fetch from API
+ fullTrack, err := c.fetchFullTrack(ctx, trackID)
+ if err != nil {
+ return "", err
+ }
+
+ // Cache the result
+ c.cacheMu.Lock()
+ c.isrcCache[trackID] = fullTrack.ISRC
+ c.cacheMu.Unlock()
+
+ return fullTrack.ISRC, nil
+}
+
+
+
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
if artist.PictureXL != "" {
return artist.PictureXL
diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go
index 8ec8741c..a637c041 100644
--- a/go_backend/duplicate.go
+++ b/go_backend/duplicate.go
@@ -1,49 +1,144 @@
package gobackend
import (
+ "encoding/json"
+ "fmt"
"os"
"path/filepath"
"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)
+// Uses ISRC index for fast lookup
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
if isrc == "" || outputDir == "" {
return "", false
}
- // Walk through directory looking for FLAC files
- var foundFile string
- filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
- 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
+ // Use index for fast lookup
+ idx := GetISRCIndex(outputDir)
+ return idx.lookup(isrc)
}
// 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
}
+
+// 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)
+ }
+}
diff --git a/go_backend/exports.go b/go_backend/exports.go
index 158526d8..249ac1b5 100644
--- a/go_backend/exports.go
+++ b/go_backend/exports.go
@@ -143,18 +143,32 @@ type DownloadResponse struct {
Message string `json:"message"`
FilePath string `json:"file_path,omitempty"`
Error string `json:"error,omitempty"`
+ ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
AlreadyExists bool `json:"already_exists,omitempty"`
// Actual quality info from the source
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
Service string `json:"service,omitempty"` // Actual service used (for fallback)
+ Title string `json:"title,omitempty"`
+ Artist string `json:"artist,omitempty"`
+ Album string `json:"album,omitempty"`
+ ReleaseDate string `json:"release_date,omitempty"`
+ TrackNumber int `json:"track_number,omitempty"`
+ DiscNumber int `json:"disc_number,omitempty"`
}
+// DownloadResult is a generic result type for all downloaders
// DownloadResult is a generic result type for all downloaders
type DownloadResult struct {
- FilePath string
- BitDepth int
- SampleRate int
+ FilePath string
+ BitDepth int
+ SampleRate int
+ Title string
+ Artist string
+ Album string
+ ReleaseDate string
+ TrackNumber int
+ DiscNumber int
}
// DownloadTrack downloads a track from the specified service
@@ -181,9 +195,15 @@ func DownloadTrack(requestJSON string) (string, error) {
tidalResult, tidalErr := downloadFromTidal(req)
if tidalErr == nil {
result = DownloadResult{
- FilePath: tidalResult.FilePath,
- BitDepth: tidalResult.BitDepth,
- SampleRate: tidalResult.SampleRate,
+ FilePath: tidalResult.FilePath,
+ BitDepth: tidalResult.BitDepth,
+ SampleRate: tidalResult.SampleRate,
+ Title: tidalResult.Title,
+ Artist: tidalResult.Artist,
+ Album: tidalResult.Album,
+ ReleaseDate: tidalResult.ReleaseDate,
+ TrackNumber: tidalResult.TrackNumber,
+ DiscNumber: tidalResult.DiscNumber,
}
}
err = tidalErr
@@ -191,9 +211,15 @@ func DownloadTrack(requestJSON string) (string, error) {
qobuzResult, qobuzErr := downloadFromQobuz(req)
if qobuzErr == nil {
result = DownloadResult{
- FilePath: qobuzResult.FilePath,
- BitDepth: qobuzResult.BitDepth,
- SampleRate: qobuzResult.SampleRate,
+ FilePath: qobuzResult.FilePath,
+ BitDepth: qobuzResult.BitDepth,
+ SampleRate: qobuzResult.SampleRate,
+ Title: qobuzResult.Title,
+ Artist: qobuzResult.Artist,
+ Album: qobuzResult.Album,
+ ReleaseDate: qobuzResult.ReleaseDate,
+ TrackNumber: qobuzResult.TrackNumber,
+ DiscNumber: qobuzResult.DiscNumber,
}
}
err = qobuzErr
@@ -201,9 +227,15 @@ func DownloadTrack(requestJSON string) (string, error) {
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
- FilePath: amazonResult.FilePath,
- BitDepth: amazonResult.BitDepth,
- SampleRate: amazonResult.SampleRate,
+ FilePath: amazonResult.FilePath,
+ BitDepth: amazonResult.BitDepth,
+ SampleRate: amazonResult.SampleRate,
+ Title: amazonResult.Title,
+ Artist: amazonResult.Artist,
+ Album: amazonResult.Album,
+ ReleaseDate: amazonResult.ReleaseDate,
+ TrackNumber: amazonResult.TrackNumber,
+ DiscNumber: amazonResult.DiscNumber,
}
}
err = amazonErr
@@ -254,6 +286,12 @@ func DownloadTrack(requestJSON string) (string, error) {
ActualBitDepth: result.BitDepth,
ActualSampleRate: result.SampleRate,
Service: req.Service,
+ Title: result.Title,
+ Artist: result.Artist,
+ Album: result.Album,
+ ReleaseDate: result.ReleaseDate,
+ TrackNumber: result.TrackNumber,
+ DiscNumber: result.DiscNumber,
}
jsonBytes, _ := json.Marshal(resp)
@@ -279,7 +317,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
allServices := []string{"qobuz", "tidal", "amazon"}
preferredService := req.Service
if preferredService == "" {
- preferredService = "qobuz"
+ preferredService = "tidal"
}
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)
if tidalErr == nil {
result = DownloadResult{
- FilePath: tidalResult.FilePath,
- BitDepth: tidalResult.BitDepth,
- SampleRate: tidalResult.SampleRate,
+ FilePath: tidalResult.FilePath,
+ BitDepth: tidalResult.BitDepth,
+ SampleRate: tidalResult.SampleRate,
+ Title: tidalResult.Title,
+ Artist: tidalResult.Artist,
+ Album: tidalResult.Album,
+ ReleaseDate: tidalResult.ReleaseDate,
+ TrackNumber: tidalResult.TrackNumber,
+ DiscNumber: tidalResult.DiscNumber,
}
+ } else {
+ fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
}
err = tidalErr
case "qobuz":
@@ -322,16 +368,26 @@ func DownloadWithFallback(requestJSON string) (string, error) {
BitDepth: qobuzResult.BitDepth,
SampleRate: qobuzResult.SampleRate,
}
+ } else {
+ fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
}
err = qobuzErr
case "amazon":
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
- FilePath: amazonResult.FilePath,
- BitDepth: amazonResult.BitDepth,
- SampleRate: amazonResult.SampleRate,
+ FilePath: amazonResult.FilePath,
+ BitDepth: amazonResult.BitDepth,
+ SampleRate: amazonResult.SampleRate,
+ Title: amazonResult.Title,
+ Artist: amazonResult.Artist,
+ Album: amazonResult.Album,
+ ReleaseDate: amazonResult.ReleaseDate,
+ TrackNumber: amazonResult.TrackNumber,
+ DiscNumber: amazonResult.DiscNumber,
}
+ } else {
+ fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
}
err = amazonErr
}
@@ -443,6 +499,26 @@ func CheckDuplicate(outputDir, isrc string) (string, error) {
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
func BuildFilename(template string, metadataJSON string) (string, error) {
var metadata map[string]interface{}
@@ -483,7 +559,7 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
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
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
// Try to extract from file first (much faster)
@@ -501,7 +577,8 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
return "", err
}
- lrcContent := convertToLRC(lyricsData)
+ // Convert to LRC format with metadata headers (like PC version)
+ lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
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")
}
+// ==================== 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) {
+ // 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{
- Success: false,
- Error: msg,
+ Success: false,
+ Error: msg,
+ ErrorType: errorType,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
diff --git a/go_backend/httputil.go b/go_backend/httputil.go
index 5977c4f0..5a331468 100644
--- a/go_backend/httputil.go
+++ b/go_backend/httputil.go
@@ -12,25 +12,59 @@ import (
// HTTP utility functions for consistent request handling across all downloaders
-// User-Agent pool for Android Chrome browsers
-var userAgentTemplates = []string{
- "Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
- "Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
- "Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
- "Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
+// getRandomUserAgent generates a random Windows Chrome User-Agent string
+// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
+func getRandomUserAgent() string {
+ // Windows 10/11 Chrome format - same as PC version for maximum compatibility
+ // Some APIs may block mobile User-Agents, so we use desktop format
+ 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)
-func getRandomUserAgent() string {
- template := userAgentTemplates[rand.Intn(len(userAgentTemplates))]
+// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
+// Alternative format matching referensi/backend/spotify_metadata.go exactly
+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
- deviceModel := rand.Intn(900) + 100 // Random model number
- chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
- chromeBuild := rand.Intn(5000) + 5000
- chromePatch := rand.Intn(200) + 100
+ return fmt.Sprintf(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
+ macMajor,
+ macMinor,
+ webkitMajor,
+ webkitMinor,
+ chromeMajor,
+ chromeBuild,
+ chromePatch,
+ safariMajor,
+ safariMinor,
+ )
+}
- 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
diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go
index f05a3143..9d18b46f 100644
--- a/go_backend/lyrics.go
+++ b/go_backend/lyrics.go
@@ -248,6 +248,8 @@ func msToLRCTimestamp(ms int64) string {
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 {
if lyrics == nil || len(lyrics.Lines) == 0 {
return ""
@@ -272,6 +274,45 @@ func convertToLRC(lyrics *LyricsResponse) 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 {
patterns := []string{
`\s*\(feat\..*?\)`,
diff --git a/go_backend/metadata.go b/go_backend/metadata.go
index ff4a2f58..1816175e 100644
--- a/go_backend/metadata.go
+++ b/go_backend/metadata.go
@@ -382,6 +382,7 @@ type AudioQuality struct {
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
+// For M4A files, it delegates to GetM4AQuality
func GetAudioQuality(filePath string) (AudioQuality, error) {
file, err := os.Open(filePath)
if err != nil {
@@ -389,45 +390,401 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
}
defer file.Close()
- // Read FLAC marker (4 bytes: "fLaC")
+ // Read first 4 bytes to detect file type
marker := make([]byte, 4)
if _, err := file.Read(marker); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
}
- 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)
- // Byte 0: bit 7 = last block flag, bits 0-6 = block type (0 = STREAMINFO)
- // Bytes 1-3: block length (24-bit big-endian)
- header := make([]byte, 4)
- if _, err := file.Read(header); err != nil {
+ blockType := header[0] & 0x7F
+ if blockType != 0 {
+ return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
+ }
+
+ // Read STREAMINFO block (34 bytes minimum)
+ streamInfo := make([]byte, 34)
+ if _, err := file.Read(streamInfo); err != nil {
+ return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
+ }
+
+ // Parse sample rate (20 bits starting at byte 10)
+ sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
+
+ // Parse bits per sample (5 bits)
+ bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
+
+ return AudioQuality{
+ BitDepth: bitsPerSample,
+ SampleRate: sampleRate,
+ }, nil
+ }
+
+ // Check if it's an M4A/MP4 file (starts with size + "ftyp")
+ // First 4 bytes are size, next 4 should be "ftyp"
+ file.Seek(0, 0) // Reset to beginning
+ header8 := make([]byte, 8)
+ if _, err := file.Read(header8); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
}
-
- blockType := header[0] & 0x7F
- if blockType != 0 {
- return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
+
+ if string(header8[4:8]) == "ftyp" {
+ // It's an M4A/MP4 file, use M4A quality reader
+ file.Close() // Close before calling GetM4AQuality which opens the file again
+ return GetM4AQuality(filePath)
}
-
- // Read STREAMINFO block (34 bytes minimum)
- // 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)
- }
-
- // Parse sample rate (20 bits starting at byte 10)
- // Bytes 10-12: [SSSS SSSS] [SSSS SSSS] [SSSS CCCC] where S=sample rate, C=channels
- sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
-
- // Parse bits per sample (5 bits)
- // Byte 12 bits 0-3 and byte 13 bit 7: [.... BBBB] [B...] where B=bits per sample - 1
- bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
-
- return AudioQuality{
- BitDepth: bitsPerSample,
- SampleRate: sampleRate,
- }, nil
+
+ return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
+}
+
+
+// ========================================
+// M4A (MP4/AAC) Metadata Embedding
+// ========================================
+
+// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
+// This is a simplified implementation that writes metadata to the file
+func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
+ // Read the entire file
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return fmt.Errorf("failed to read M4A file: %w", err)
+ }
+
+ // Find moov atom position
+ moovPos := findAtom(data, "moov", 0)
+ if moovPos < 0 {
+ return fmt.Errorf("moov atom not found in M4A file")
+ }
+
+ // Find udta atom inside moov, or create one
+ moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
+ udtaPos := findAtom(data, "udta", moovPos+8)
+
+ // Build new metadata atoms
+ metaAtom := buildMetaAtom(metadata, coverData)
+
+ var newData []byte
+ if udtaPos >= 0 && udtaPos < moovPos+moovSize {
+ // udta exists, find meta inside it or replace
+ udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
+ metaPos := findAtom(data, "meta", udtaPos+8)
+
+ if metaPos >= 0 && metaPos < udtaPos+udtaSize {
+ // Replace existing meta atom
+ metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
+ newData = append(newData, data[:metaPos]...)
+ newData = append(newData, metaAtom...)
+ newData = append(newData, data[metaPos+metaSize:]...)
+ } else {
+ // Add meta atom to udta
+ newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
+ newUdtaSize := 8 + len(newUdtaContent)
+ newUdta := make([]byte, 4)
+ newUdta[0] = byte(newUdtaSize >> 24)
+ newUdta[1] = byte(newUdtaSize >> 16)
+ newUdta[2] = byte(newUdtaSize >> 8)
+ newUdta[3] = byte(newUdtaSize)
+ newUdta = append(newUdta, []byte("udta")...)
+ newUdta = append(newUdta, newUdtaContent...)
+
+ newData = append(newData, data[:udtaPos]...)
+ newData = append(newData, newUdta...)
+ newData = append(newData, data[udtaPos+udtaSize:]...)
+ }
+ } else {
+ // Create new udta with meta
+ udtaContent := metaAtom
+ udtaSize := 8 + len(udtaContent)
+ newUdta := make([]byte, 4)
+ newUdta[0] = byte(udtaSize >> 24)
+ newUdta[1] = byte(udtaSize >> 16)
+ newUdta[2] = byte(udtaSize >> 8)
+ newUdta[3] = byte(udtaSize)
+ newUdta = append(newUdta, []byte("udta")...)
+ newUdta = append(newUdta, udtaContent...)
+
+ // Insert udta at end of moov
+ insertPos := moovPos + moovSize
+ newData = append(newData, data[:insertPos]...)
+ newData = append(newData, newUdta...)
+ newData = append(newData, data[insertPos:]...)
+ }
+
+ // Update moov size
+ newMoovSize := moovSize + len(newData) - len(data)
+ newData[moovPos] = byte(newMoovSize >> 24)
+ newData[moovPos+1] = byte(newMoovSize >> 16)
+ newData[moovPos+2] = byte(newMoovSize >> 8)
+ newData[moovPos+3] = byte(newMoovSize)
+
+ // Write back to file
+ if err := os.WriteFile(filePath, newData, 0644); err != nil {
+ return fmt.Errorf("failed to write M4A file: %w", err)
+ }
+
+ fmt.Printf("[M4A] Metadata embedded successfully\n")
+ return nil
+}
+
+// findAtom finds an atom by name starting from offset
+func findAtom(data []byte, name string, offset int) int {
+ for i := offset; i < len(data)-8; {
+ size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
+ if size < 8 {
+ break
+ }
+ atomName := string(data[i+4 : i+8])
+ if atomName == name {
+ return i
+ }
+ i += size
+ }
+ return -1
+}
+
+// buildMetaAtom builds a complete meta atom with ilst containing metadata
+func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
+ // Build ilst content
+ var ilst []byte
+
+ // ©nam - Title
+ if metadata.Title != "" {
+ ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
+ }
+
+ // ©ART - Artist
+ if metadata.Artist != "" {
+ ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
+ }
+
+ // ©alb - Album
+ if metadata.Album != "" {
+ ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
+ }
+
+ // aART - Album Artist
+ if metadata.AlbumArtist != "" {
+ ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
+ }
+
+ // ©day - Year/Date
+ if metadata.Date != "" {
+ ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
+ }
+
+ // trkn - Track Number
+ if metadata.TrackNumber > 0 {
+ ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
+ }
+
+ // disk - Disc Number
+ if metadata.DiscNumber > 0 {
+ ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
+ }
+
+ // ©lyr - Lyrics
+ if metadata.Lyrics != "" {
+ ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
+ }
+
+ // covr - Cover Art
+ if len(coverData) > 0 {
+ ilst = append(ilst, buildCoverAtom(coverData)...)
+ }
+
+ // Build ilst atom
+ ilstSize := 8 + len(ilst)
+ ilstAtom := make([]byte, 4)
+ ilstAtom[0] = byte(ilstSize >> 24)
+ ilstAtom[1] = byte(ilstSize >> 16)
+ ilstAtom[2] = byte(ilstSize >> 8)
+ ilstAtom[3] = byte(ilstSize)
+ ilstAtom = append(ilstAtom, []byte("ilst")...)
+ ilstAtom = append(ilstAtom, ilst...)
+
+ // Build hdlr atom (required for meta)
+ hdlr := []byte{
+ 0, 0, 0, 33, // size = 33
+ 'h', 'd', 'l', 'r',
+ 0, 0, 0, 0, // version + flags
+ 0, 0, 0, 0, // predefined
+ 'm', 'd', 'i', 'r', // handler type
+ 'a', 'p', 'p', 'l', // manufacturer
+ 0, 0, 0, 0, // component flags
+ 0, 0, 0, 0, // component flags mask
+ 0, // null terminator
+ }
+
+ // Build meta atom
+ metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
+ metaContent = append(metaContent, ilstAtom...)
+
+ metaSize := 8 + len(metaContent)
+ metaAtom := make([]byte, 4)
+ metaAtom[0] = byte(metaSize >> 24)
+ metaAtom[1] = byte(metaSize >> 16)
+ metaAtom[2] = byte(metaSize >> 8)
+ metaAtom[3] = byte(metaSize)
+ metaAtom = append(metaAtom, []byte("meta")...)
+ metaAtom = append(metaAtom, metaContent...)
+
+ return metaAtom
+}
+
+// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
+func buildTextAtom(name, value string) []byte {
+ valueBytes := []byte(value)
+
+ // data atom
+ dataSize := 16 + len(valueBytes)
+ dataAtom := make([]byte, 4)
+ dataAtom[0] = byte(dataSize >> 24)
+ dataAtom[1] = byte(dataSize >> 16)
+ dataAtom[2] = byte(dataSize >> 8)
+ dataAtom[3] = byte(dataSize)
+ dataAtom = append(dataAtom, []byte("data")...)
+ dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
+ dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
+ dataAtom = append(dataAtom, valueBytes...)
+
+ // container atom
+ atomSize := 8 + len(dataAtom)
+ atom := make([]byte, 4)
+ atom[0] = byte(atomSize >> 24)
+ atom[1] = byte(atomSize >> 16)
+ atom[2] = byte(atomSize >> 8)
+ atom[3] = byte(atomSize)
+ atom = append(atom, []byte(name)...)
+ atom = append(atom, dataAtom...)
+
+ return atom
+}
+
+// buildTrackNumberAtom builds trkn atom
+func buildTrackNumberAtom(track, total int) []byte {
+ // data atom with track number
+ dataAtom := []byte{
+ 0, 0, 0, 24, // size
+ 'd', 'a', 't', 'a',
+ 0, 0, 0, 0, // type = implicit
+ 0, 0, 0, 0, // locale
+ 0, 0, // padding
+ byte(track >> 8), byte(track), // track number
+ byte(total >> 8), byte(total), // total tracks
+ 0, 0, // padding
+ }
+
+ // trkn atom
+ atomSize := 8 + len(dataAtom)
+ atom := make([]byte, 4)
+ atom[0] = byte(atomSize >> 24)
+ atom[1] = byte(atomSize >> 16)
+ atom[2] = byte(atomSize >> 8)
+ atom[3] = byte(atomSize)
+ atom = append(atom, []byte("trkn")...)
+ atom = append(atom, dataAtom...)
+
+ return atom
+}
+
+// buildDiscNumberAtom builds disk atom
+func buildDiscNumberAtom(disc, total int) []byte {
+ // data atom with disc number
+ dataAtom := []byte{
+ 0, 0, 0, 22, // size
+ 'd', 'a', 't', 'a',
+ 0, 0, 0, 0, // type = implicit
+ 0, 0, 0, 0, // locale
+ 0, 0, // padding
+ byte(disc >> 8), byte(disc), // disc number
+ byte(total >> 8), byte(total), // total discs
+ }
+
+ // disk atom
+ atomSize := 8 + len(dataAtom)
+ atom := make([]byte, 4)
+ atom[0] = byte(atomSize >> 24)
+ atom[1] = byte(atomSize >> 16)
+ atom[2] = byte(atomSize >> 8)
+ atom[3] = byte(atomSize)
+ atom = append(atom, []byte("disk")...)
+ atom = append(atom, dataAtom...)
+
+ return atom
+}
+
+// buildCoverAtom builds covr atom with image data
+func buildCoverAtom(coverData []byte) []byte {
+ // Detect image type (JPEG = 13, PNG = 14)
+ imageType := byte(13) // default JPEG
+ if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
+ imageType = 14 // PNG
+ }
+
+ // data atom
+ dataSize := 16 + len(coverData)
+ dataAtom := make([]byte, 4)
+ dataAtom[0] = byte(dataSize >> 24)
+ dataAtom[1] = byte(dataSize >> 16)
+ dataAtom[2] = byte(dataSize >> 8)
+ dataAtom[3] = byte(dataSize)
+ dataAtom = append(dataAtom, []byte("data")...)
+ dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
+ dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
+ dataAtom = append(dataAtom, coverData...)
+
+ // covr atom
+ atomSize := 8 + len(dataAtom)
+ atom := make([]byte, 4)
+ atom[0] = byte(atomSize >> 24)
+ atom[1] = byte(atomSize >> 16)
+ atom[2] = byte(atomSize >> 8)
+ atom[3] = byte(atomSize)
+ atom = append(atom, []byte("covr")...)
+ atom = append(atom, dataAtom...)
+
+ return atom
+}
+
+// GetM4AQuality reads audio quality from M4A file
+func GetM4AQuality(filePath string) (AudioQuality, error) {
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
+ }
+
+ // Find moov -> trak -> mdia -> minf -> stbl -> stsd
+ moovPos := findAtom(data, "moov", 0)
+ if moovPos < 0 {
+ return AudioQuality{}, fmt.Errorf("moov atom not found")
+ }
+
+ // Search for mp4a or alac atom which contains audio info
+ // This is a simplified search - real implementation would traverse the atom tree
+ for i := moovPos; i < len(data)-20; i++ {
+ if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
+ // Sample rate is at offset 22-23 from atom start (16-bit big-endian)
+ if i+24 < len(data) {
+ sampleRate := int(data[i+22])<<8 | int(data[i+23])
+ // For AAC, bit depth is typically 16
+ bitDepth := 16
+ if string(data[i:i+4]) == "alac" {
+ // ALAC can have higher bit depth, check esds or alac specific data
+ bitDepth = 24 // Assume 24-bit for ALAC
+ }
+ return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
+ }
+ }
+ }
+
+ return AudioQuality{}, fmt.Errorf("audio info not found in M4A file")
}
diff --git a/go_backend/parallel.go b/go_backend/parallel.go
index 8e5496e3..ffac2dc7 100644
--- a/go_backend/parallel.go
+++ b/go_backend/parallel.go
@@ -165,7 +165,8 @@ func FetchCoverAndLyricsParallel(
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
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))
} else {
result.LyricsErr = fmt.Errorf("no lyrics found")
diff --git a/go_backend/progress.go b/go_backend/progress.go
index 72badb8b..f1a0dfe1 100644
--- a/go_backend/progress.go
+++ b/go_backend/progress.go
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"sync"
+ "time"
)
// DownloadProgress represents current download progress
@@ -23,6 +24,7 @@ type ItemProgress struct {
BytesTotal int64 `json:"bytes_total"`
BytesReceived int64 `json:"bytes_received"`
Progress float64 `json:"progress"` // 0.0 to 1.0
+ SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
IsDownloading bool `json:"is_downloading"`
Status string `json:"status"` // "downloading", "finalizing", "completed"
}
@@ -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
func CompleteItemProgress(itemID string) {
multiMu.Lock()
@@ -199,22 +215,29 @@ type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
itemID string
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
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
+ now := time.Now()
return &ItemProgressWriter{
writer: w,
itemID: itemID,
current: 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) {
n, err := pw.writer.Write(p)
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
// Also update on first write to show download has started
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.lastTime = now
+ pw.lastBytes = pw.current
}
return n, nil
}
diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go
index a5c1d0bf..a9f8c199 100644
--- a/go_backend/qobuz.go
+++ b/go_backend/qobuz.go
@@ -105,6 +105,16 @@ func qobuzIsASCIIString(s string) bool {
return true
}
+// containsQueryQobuz checks if a query already exists in the list
+func containsQueryQobuz(queries []string, query string) bool {
+ for _, q := range queries {
+ if q == query {
+ return true
+ }
+ }
+ return false
+}
+
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
func NewQobuzDownloader() *QobuzDownloader {
qobuzDownloaderOnce.Do(func() {
@@ -270,10 +280,11 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
}
// SearchTrackByMetadataWithDuration searches for a track with duration verification
+// Now includes romaji conversion for Japanese text (same as Tidal)
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
- // Try multiple search strategies
+ // Try multiple search strategies (same as Tidal/PC version)
queries := []string{}
// Strategy 1: Artist + Track name
@@ -286,10 +297,54 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
queries = append(queries, trackName)
}
+ // Strategy 3: Romaji versions if Japanese detected
+ if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
+ // Convert to romaji (hiragana/katakana only, kanji stays)
+ romajiTrack := JapaneseToRomaji(trackName)
+ romajiArtist := JapaneseToRomaji(artistName)
+
+ // Clean and remove ALL non-ASCII characters (including kanji)
+ cleanRomajiTrack := CleanToASCII(romajiTrack)
+ cleanRomajiArtist := CleanToASCII(romajiArtist)
+
+ // Artist + Track romaji (cleaned to ASCII only)
+ if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
+ romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
+ if !containsQueryQobuz(queries, romajiQuery) {
+ queries = append(queries, romajiQuery)
+ fmt.Printf("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
+ }
+ }
+
+ // Track romaji only (cleaned)
+ if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
+ if !containsQueryQobuz(queries, cleanRomajiTrack) {
+ queries = append(queries, cleanRomajiTrack)
+ }
+ }
+ }
+
+ // Strategy 4: Artist only as last resort
+ if artistName != "" {
+ artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
+ if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
+ queries = append(queries, artistOnly)
+ }
+ }
+
var allTracks []QobuzTrack
+ searchedQueries := make(map[string]bool)
for _, query := range queries {
- 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)
if err != nil {
@@ -298,6 +353,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
+ fmt.Printf("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
continue
}
@@ -318,6 +374,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
resp.Body.Close()
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...)
}
}
@@ -526,9 +583,15 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
// QobuzDownloadResult contains download result with quality info
type QobuzDownloadResult struct {
- FilePath string
- BitDepth int
- SampleRate int
+ FilePath string
+ BitDepth int
+ SampleRate int
+ Title string
+ Artist string
+ Album string
+ ReleaseDate string
+ TrackNumber int
+ DiscNumber int
}
// downloadFromQobuz downloads a track using the request parameters
@@ -668,16 +731,17 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
// Embed metadata using parallel-fetched cover data
+ // Use metadata from the actual Qobuz track found (more accurate than request)
metadata := Metadata{
- Title: req.TrackName,
- Artist: req.ArtistName,
- Album: req.AlbumName,
- AlbumArtist: req.AlbumArtist,
- Date: req.ReleaseDate,
- TrackNumber: req.TrackNumber,
+ Title: track.Title,
+ Artist: track.Performer.Name,
+ Album: track.Album.Title,
+ AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct
+ Date: track.Album.ReleaseDate,
+ TrackNumber: track.TrackNumber,
TotalTracks: req.TotalTracks,
- DiscNumber: req.DiscNumber,
- ISRC: req.ISRC,
+ DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
+ ISRC: track.ISRC,
}
// 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")
}
+ // Add to ISRC index for fast duplicate checking
+ AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
+
return QobuzDownloadResult{
- FilePath: outputPath,
- BitDepth: actualBitDepth,
- SampleRate: actualSampleRate,
+ FilePath: outputPath,
+ BitDepth: actualBitDepth,
+ SampleRate: actualSampleRate,
+ Title: track.Title,
+ Artist: track.Performer.Name,
+ Album: track.Album.Title,
+ ReleaseDate: track.Album.ReleaseDate,
+ TrackNumber: track.TrackNumber,
+ DiscNumber: req.DiscNumber, // Qobuz track struct limitations
}, nil
}
diff --git a/go_backend/romaji.go b/go_backend/romaji.go
new file mode 100644
index 00000000..1e1516e3
--- /dev/null
+++ b/go_backend/romaji.go
@@ -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)
+}
diff --git a/go_backend/songlink.go b/go_backend/songlink.go
index 1192f34d..02f9c1f8 100644
--- a/go_backend/songlink.go
+++ b/go_backend/songlink.go
@@ -48,6 +48,11 @@ func NewSongLinkClient() *SongLinkClient {
// CheckTrackAvailability checks track availability on streaming platforms
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
songLinkRateLimiter.WaitForSlot()
@@ -71,8 +76,18 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
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 {
- 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)
@@ -114,7 +129,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
- // Check Qobuz using ISRC
+ // Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
@@ -282,3 +297,248 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
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
+}
diff --git a/go_backend/spotify.go b/go_backend/spotify.go
index 10d47ed1..3e2d866c 100644
--- a/go_backend/spotify.go
+++ b/go_backend/spotify.go
@@ -495,6 +495,17 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
}
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 {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
@@ -502,15 +513,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
Images []image `json:"images"`
Artists []artist `json:"artists"`
Tracks struct {
- Items []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"`
- } `json:"items"`
+ Items []trackItem `json:"items"`
+ Next string `json:"next"`
} `json:"tracks"`
}
@@ -527,10 +531,38 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
Images: albumImage,
}
- tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
- for _, item := range data.Tracks.Items {
- // Fetch ISRC for each track
- isrc := c.fetchTrackISRC(ctx, item.ID, token)
+ // Collect all tracks (including paginated)
+ allTrackItems := data.Tracks.Items
+ nextURL := data.Tracks.Next
+
+ // 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{
SpotifyID: item.ID,
@@ -566,6 +598,47 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
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) {
// First request to get playlist info and first batch of tracks
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
- maxTracks := 1000
- for nextURL != "" && len(tracks) < maxTracks {
+ for nextURL != "" {
var pageData struct {
Items []struct {
Track *trackFull `json:"track"`
@@ -642,9 +714,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
if item.Track == nil {
continue
}
- if len(tracks) >= maxTracks {
- break
- }
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: item.Track.ID,
Artists: joinArtists(item.Track.Artists),
@@ -835,8 +904,16 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
return err
}
+ // Set headers (same as PC version baseHeaders)
req.Header.Set("User-Agent", c.userAgent)
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 != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
@@ -863,13 +940,23 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
c.rngMu.Lock()
defer c.rngMu.Unlock()
- chromeMajor := 80 + c.rng.Intn(25)
- chromeBuild := 3000 + c.rng.Intn(1500)
- chromePatch := 60 + c.rng.Intn(65)
+ // Use Mac User-Agent format (same as PC version)
+ macMajor := c.rng.Intn(4) + 11 // 11-14
+ 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(
- "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,
+ safariMajor, safariMinor,
)
}
diff --git a/go_backend/tidal.go b/go_backend/tidal.go
index bb8178fc..2865cadc 100644
--- a/go_backend/tidal.go
+++ b/go_backend/tidal.go
@@ -128,14 +128,16 @@ func NewTidalDownloader() *TidalDownloader {
// GetAvailableAPIs returns list of available Tidal APIs
func (t *TidalDownloader) GetAvailableAPIs() []string {
encodedAPIs := []string{
- "dm9nZWwucXFkbC5zaXRl", // API 1 - vogel.qqdl.site
- "bWF1cy5xcWRsLnNpdGU=", // API 2 - maus.qqdl.site
- "aHVuZC5xcWRsLnNpdGU=", // API 3 - hund.qqdl.site
- "a2F0emUucXFkbC5zaXRl", // API 4 - katze.qqdl.site
- "d29sZi5xcWRsLnNpdGU=", // API 5 - wolf.qqdl.site
- "dGlkYWwua2lub3BsdXMub25saW5l", // API 6 - tidal.kinoplus.online
- "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // API 7 - tidal-api.binimum.org
- "dHJpdG9uLnNxdWlkLnd0Zg==", // API 8 - triton.squid.wtf
+ // Priority 1: APIs that return FULL tracks (not PREVIEW)
+ "dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - returns FULL
+ "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
+ "dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
+ // Priority 2: qqdl.site APIs (often return PREVIEW only)
+ "dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
+ "bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
+ "aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
+ "a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
+ "d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
}
var apis []string
@@ -367,13 +369,14 @@ func normalizeTitle(title string) string {
}
// 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) {
token, err := t.GetAccessToken()
if err != nil {
return nil, err
}
- // Build search queries - multiple strategies
+ // Build search queries - multiple strategies (same as PC version)
queries := []string{}
// Strategy 1: Artist + Track name (original)
@@ -386,9 +389,47 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
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 != "" {
- queries = append(queries, artistName)
+ artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
+ if artistOnly != "" && !containsQuery(queries, artistOnly) {
+ queries = append(queries, artistOnly)
+ }
}
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
@@ -404,6 +445,8 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
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))
req, err := http.NewRequest("GET", searchURL, nil)
@@ -415,6 +458,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
resp, err := DoRequestWithUserAgent(t.client, req)
if err != nil {
+ fmt.Printf("[Tidal] Search error for '%s': %v\n", cleanQuery, err)
continue
}
@@ -433,6 +477,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
resp.Body.Close()
if len(result.Items) > 0 {
+ fmt.Printf("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
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
if spotifyISRC != "" {
+ fmt.Printf("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
var isrcMatches []*TidalTrack
for i := range allTracks {
track := &allTracks[i]
@@ -460,15 +506,15 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
if durationDiff < 0 {
durationDiff = -durationDiff
}
- // Allow 30 seconds tolerance for duration
- if durationDiff <= 30 {
+ // Allow 3 seconds tolerance for duration (same as PC version)
+ if durationDiff <= 3 {
durationVerifiedMatches = append(durationVerifiedMatches, track)
}
}
if len(durationVerifiedMatches) > 0 {
// 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)
return durationVerifiedMatches[0], nil
}
@@ -481,11 +527,12 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
}
// 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
}
// 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)
}
@@ -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
}
}
@@ -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
}
+// 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
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
@@ -564,6 +626,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
for _, apiURL := range apis {
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)
if err != nil {
@@ -573,6 +636,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
resp, err := DoRequestWithRetry(client, req, retryConfig)
if err != nil {
+ fmt.Printf("[Tidal] API error: %v\n", err)
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
continue
}
@@ -580,13 +644,32 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
body, err := ReadResponseBody(resp)
resp.Body.Close()
if err != nil {
+ fmt.Printf("[Tidal] Read body error: %v\n", err)
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
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)
var v2Response TidalAPIResponseV2
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{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
@@ -642,6 +725,13 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
}
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)
if strings.HasPrefix(manifestStr, "{") {
@@ -691,21 +781,31 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
// Calculate segment count from timeline
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
}
+ fmt.Printf("[Tidal] Segment count from XML: %d\n", segmentCount)
// If no segments found via XML, try regex
if segmentCount == 0 {
- segRe := regexp.MustCompile(` or
+ segRe := regexp.MustCompile(` 1 && match[1] != "" {
- fmt.Sscanf(match[1], "%d", &repeat)
+ if len(match) > 2 && match[2] != "" {
+ 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
}
+ fmt.Printf("[Tidal] Total segments from regex: %d\n", segmentCount)
}
// 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
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
- // Handle manifest-based download
+ // Handle manifest-based download (DASH/BTS)
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)
}
- // Initialize item progress (required for all downloads)
+ // Initialize item progress for direct downloads
if itemID != "" {
StartItemProgress(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 {
+ fmt.Println("[Tidal] Parsing manifest...")
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
if err != nil {
+ fmt.Printf("[Tidal] Manifest parse error: %v\n", 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{
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 directURL != "" {
- // Initialize item progress (required for all downloads)
- if itemID != "" {
- StartItemProgress(itemID)
- defer CompleteItemProgress(itemID)
- }
+ fmt.Printf("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
+ // Note: Progress tracking is initialized by the caller (DownloadFile)
req, err := http.NewRequest("GET", directURL, nil)
if err != nil {
+ fmt.Printf("[Tidal] BTS request creation failed: %v\n", err)
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
+ fmt.Printf("[Tidal] BTS download failed: %v\n", err)
return fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
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)
}
+ fmt.Printf("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
expectedSize := resp.ContentLength
// Set total bytes for progress tracking
@@ -870,79 +980,103 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
return nil
}
- // DASH format - download segments to temporary file
- // Note: On Android, we can't use ffmpeg, so we'll try to download as M4A
- // and hope the player can handle it, or we save as .m4a instead of .flac
- tempPath := outputPath + ".m4a.tmp"
- out, err := os.Create(tempPath)
+ // DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
+ // On Android, we can't use ffmpeg, so we save as M4A directly
+ m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
+ fmt.Printf("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
+
+ // 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 {
- 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
+ fmt.Printf("[Tidal] Downloading init segment...\n")
resp, err := client.Get(initURL)
if err != nil {
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)
}
if resp.StatusCode != 200 {
resp.Body.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)
}
_, err = io.Copy(out, resp.Body)
resp.Body.Close()
if err != nil {
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)
}
- // Download media segments
+ // Download media segments with progress
+ totalSegments := len(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)
if err != nil {
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)
}
if resp.StatusCode != 200 {
resp.Body.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)
}
_, err = io.Copy(out, resp.Body)
resp.Body.Close()
if err != nil {
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)
}
}
- out.Close()
-
- // For Android, we'll save as M4A since we can't use ffmpeg
- // Rename temp file to final output (change extension to .m4a if needed)
- 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 err := out.Close(); err != nil {
+ os.Remove(m4aPath)
+ fmt.Printf("[Tidal] Failed to close M4A file: %v\n", err)
+ return fmt.Errorf("failed to close M4A file: %w", err)
}
- // If the original output was .flac, we need to indicate this is actually m4a
- // For now, we'll just keep it as m4a
+ fmt.Printf("[Tidal] DASH download completed: %s\n", m4aPath)
return nil
}
// TidalDownloadResult contains download result with quality info
type TidalDownloadResult struct {
- FilePath string
- BitDepth int
- SampleRate int
+ FilePath string
+ BitDepth int
+ SampleRate int
+ Title string
+ Artist string
+ Album string
+ ReleaseDate string
+ TrackNumber int
+ DiscNumber int
}
// artistsMatch checks if the artist names are similar enough
@@ -1086,8 +1220,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if durationDiff < 0 {
durationDiff = -durationDiff
}
- // Allow 30 seconds tolerance
- if durationDiff > 30 {
+ // Allow 3 seconds tolerance (same as PC version)
+ if durationDiff > 3 {
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
@@ -1156,10 +1290,21 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
filename = sanitizeFilename(filename) + ".flac"
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 {
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)
quality := req.Quality
@@ -1193,9 +1338,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}()
// 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 {
+ fmt.Printf("[Tidal] Download failed with error: %v\n", err)
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
+ fmt.Println("[Tidal] Download completed successfully")
// Wait for parallel operations to complete
<-parallelDone
@@ -1208,9 +1363,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
// 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
- m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
if _, err := os.Stat(m4aPath); err == nil {
// File was saved as M4A, use that path
actualOutputPath = m4aPath
@@ -1240,7 +1394,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
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 err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
@@ -1257,13 +1411,40 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} else if req.EmbedLyrics {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
- } else {
- fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
+ } else if strings.HasSuffix(actualOutputPath, ".m4a") {
+ // 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{
- FilePath: actualOutputPath,
- BitDepth: downloadInfo.BitDepth,
- SampleRate: downloadInfo.SampleRate,
+ FilePath: actualOutputPath,
+ BitDepth: downloadInfo.BitDepth,
+ 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
}
diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart
index 2b87ca36..a0316f31 100644
--- a/lib/constants/app_info.dart
+++ b/lib/constants/app_info.dart
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
- static const String version = '2.1.5';
- static const String buildNumber = '43';
+ static const String version = '2.1.6';
+ static const String buildNumber = '44';
static const String fullVersion = '$version+$buildNumber';
diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart
index 4c021e0d..cf109142 100644
--- a/lib/models/download_item.dart
+++ b/lib/models/download_item.dart
@@ -13,6 +13,14 @@ enum DownloadStatus {
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()
class DownloadItem {
final String id;
@@ -20,8 +28,10 @@ class DownloadItem {
final String service;
final DownloadStatus status;
final double progress;
+ final double speedMBps; // Download speed in MB/s
final String? filePath;
final String? error;
+ final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
@@ -31,8 +41,10 @@ class DownloadItem {
required this.service,
this.status = DownloadStatus.queued,
this.progress = 0.0,
+ this.speedMBps = 0.0,
this.filePath,
this.error,
+ this.errorType,
required this.createdAt,
this.qualityOverride,
});
@@ -43,8 +55,10 @@ class DownloadItem {
String? service,
DownloadStatus? status,
double? progress,
+ double? speedMBps,
String? filePath,
String? error,
+ DownloadErrorType? errorType,
DateTime? createdAt,
String? qualityOverride,
}) {
@@ -54,13 +68,31 @@ class DownloadItem {
service: service ?? this.service,
status: status ?? this.status,
progress: progress ?? this.progress,
+ speedMBps: speedMBps ?? this.speedMBps,
filePath: filePath ?? this.filePath,
error: error ?? this.error,
+ errorType: errorType ?? this.errorType,
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
);
}
+ /// Get user-friendly error message based on error type
+ String get errorMessage {
+ if (error == null) return '';
+
+ switch (errorType) {
+ case DownloadErrorType.notFound:
+ return 'Song not found on any service';
+ case DownloadErrorType.rateLimit:
+ return 'Rate limit reached, try again later';
+ case DownloadErrorType.network:
+ return 'Connection failed, check your internet';
+ default:
+ return error ?? 'An error occurred';
+ }
+ }
+
factory DownloadItem.fromJson(Map json) =>
_$DownloadItemFromJson(json);
Map toJson() => _$DownloadItemToJson(this);
diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart
index 1586b8d1..5dcb97f0 100644
--- a/lib/models/download_item.g.dart
+++ b/lib/models/download_item.g.dart
@@ -14,8 +14,10 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem(
$enumDecodeNullable(_$DownloadStatusEnumMap, json['status']) ??
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
+ speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
+ errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
);
@@ -27,8 +29,10 @@ Map _$DownloadItemToJson(DownloadItem instance) =>
'service': instance.service,
'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress,
+ 'speedMBps': instance.speedMBps,
'filePath': instance.filePath,
'error': instance.error,
+ 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
};
@@ -41,3 +45,10 @@ const _$DownloadStatusEnumMap = {
DownloadStatus.failed: 'failed',
DownloadStatus.skipped: 'skipped',
};
+
+const _$DownloadErrorTypeEnumMap = {
+ DownloadErrorType.unknown: 'unknown',
+ DownloadErrorType.notFound: 'notFound',
+ DownloadErrorType.rateLimit: 'rateLimit',
+ DownloadErrorType.network: 'network',
+};
diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart
index 240114fe..021158d1 100644
--- a/lib/models/settings.g.dart
+++ b/lib/models/settings.g.dart
@@ -7,7 +7,7 @@ part of 'settings.dart';
// **************************************************************************
AppSettings _$AppSettingsFromJson(Map json) => AppSettings(
- defaultService: json['defaultService'] as String? ?? 'tidal',
+ defaultService: json['defaultService'] as String? ?? 'qobuz',
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
diff --git a/lib/models/track.dart b/lib/models/track.dart
index 68647758..7ae36ce7 100644
--- a/lib/models/track.dart
+++ b/lib/models/track.dart
@@ -16,6 +16,7 @@ class Track {
final int? trackNumber;
final int? discNumber;
final String? releaseDate;
+ final String? deezerId;
final ServiceAvailability? availability;
const Track({
@@ -30,6 +31,7 @@ class Track {
this.trackNumber,
this.discNumber,
this.releaseDate,
+ this.deezerId,
this.availability,
});
@@ -42,17 +44,23 @@ class ServiceAvailability {
final bool tidal;
final bool qobuz;
final bool amazon;
+ final bool deezer;
final String? tidalUrl;
final String? qobuzUrl;
final String? amazonUrl;
+ final String? deezerUrl;
+ final String? deezerId;
const ServiceAvailability({
this.tidal = false,
this.qobuz = false,
this.amazon = false,
+ this.deezer = false,
this.tidalUrl,
this.qobuzUrl,
this.amazonUrl,
+ this.deezerUrl,
+ this.deezerId,
});
factory ServiceAvailability.fromJson(Map json) =>
diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart
index 57e65c2b..ac78e26d 100644
--- a/lib/models/track.g.dart
+++ b/lib/models/track.g.dart
@@ -18,6 +18,7 @@ Track _$TrackFromJson(Map json) => Track(
trackNumber: (json['trackNumber'] as num?)?.toInt(),
discNumber: (json['discNumber'] as num?)?.toInt(),
releaseDate: json['releaseDate'] as String?,
+ deezerId: json['deezerId'] as String?,
availability: json['availability'] == null
? null
: ServiceAvailability.fromJson(
@@ -37,6 +38,7 @@ Map _$TrackToJson(Track instance) => {
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
+ 'deezerId': instance.deezerId,
'availability': instance.availability,
};
@@ -45,9 +47,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map json) =>
tidal: json['tidal'] as bool? ?? false,
qobuz: json['qobuz'] as bool? ?? false,
amazon: json['amazon'] as bool? ?? false,
+ deezer: json['deezer'] as bool? ?? false,
tidalUrl: json['tidalUrl'] as String?,
qobuzUrl: json['qobuzUrl'] as String?,
amazonUrl: json['amazonUrl'] as String?,
+ deezerUrl: json['deezerUrl'] as String?,
+ deezerId: json['deezerId'] as String?,
);
Map _$ServiceAvailabilityToJson(
@@ -56,7 +61,10 @@ Map _$ServiceAvailabilityToJson(
'tidal': instance.tidal,
'qobuz': instance.qobuz,
'amazon': instance.amazon,
+ 'deezer': instance.deezer,
'tidalUrl': instance.tidalUrl,
'qobuzUrl': instance.qobuzUrl,
'amazonUrl': instance.amazonUrl,
+ 'deezerUrl': instance.deezerUrl,
+ 'deezerId': instance.deezerId,
};
diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart
index 0d5dac29..1e6be94f 100644
--- a/lib/providers/download_queue_provider.dart
+++ b/lib/providers/download_queue_provider.dart
@@ -371,6 +371,7 @@ class DownloadQueueNotifier extends Notifier {
final itemProgress = entry.value as Map;
final bytesReceived = itemProgress['bytes_received'] 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 status = itemProgress['status'] as String? ?? 'downloading';
@@ -389,14 +390,29 @@ class DownloadQueueNotifier extends Notifier {
continue;
}
- if (isDownloading && bytesTotal > 0) {
- final percentage = bytesReceived / bytesTotal;
- updateProgress(itemId, percentage);
+ // Use progress from backend if available (handles both explicit progress and byte-based)
+ final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
+
+ 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 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 {
? downloadingItems.first.track.artistName
: '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(
trackName: trackName,
artistName: artistName,
- progress: bytesReceived,
- total: bytesTotal > 0 ? bytesTotal : 1,
+ progress: notifProgress,
+ total: notifTotal > 0 ? notifTotal : 1,
);
// Update foreground service notification (Android)
@@ -439,8 +466,8 @@ class DownloadQueueNotifier extends Notifier {
PlatformBridge.updateDownloadServiceProgress(
trackName: downloadingItems.first.track.name,
artistName: downloadingItems.first.track.artistName,
- progress: bytesReceived,
- total: bytesTotal > 0 ? bytesTotal : 1,
+ progress: notifProgress,
+ total: notifTotal > 0 ? notifTotal : 1,
queueCount: state.queuedCount,
).catchError((_) {}); // Ignore errors
}
@@ -609,14 +636,16 @@ class DownloadQueueNotifier extends Notifier {
}
}
- 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) {
if (item.id == id) {
return item.copyWith(
status: status,
progress: progress ?? item.progress,
+ speedMBps: speedMBps ?? item.speedMBps,
filePath: filePath,
error: error,
+ errorType: errorType,
);
}
return item;
@@ -632,8 +661,8 @@ class DownloadQueueNotifier extends Notifier {
}
}
- void updateProgress(String id, double progress) {
- updateItemStatus(id, DownloadStatus.downloading, progress: progress);
+ void updateProgress(String id, double progress, {double? speedMBps}) {
+ updateItemStatus(id, DownloadStatus.downloading, progress: progress, speedMBps: speedMBps);
}
void cancelItem(String id) {
@@ -732,18 +761,21 @@ class DownloadQueueNotifier extends Notifier {
// Download cover first
String? coverPath;
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
- coverPath = '$flacPath.cover.jpg';
try {
+ final tempDir = await getTemporaryDirectory();
+ final uniqueId = DateTime.now().millisecondsSinceEpoch;
+ coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
+
// Download cover using HTTP
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse(track.coverUrl!));
final response = await request.close();
if (response.statusCode == 200) {
- final file = File(coverPath);
+ final file = File(coverPath!);
final sink = file.openWrite();
await response.pipe(sink);
await sink.close();
- _log.d('Cover downloaded to: $coverPath');
+ _log.d('Cover downloaded to temp: $coverPath');
} else {
_log.w('Failed to download cover: HTTP ${response.statusCode}');
coverPath = null;
@@ -757,20 +789,85 @@ class DownloadQueueNotifier extends Notifier {
// Use Go backend to embed metadata
try {
- // For now, we'll use FFmpeg to embed cover since Go backend expects to download the file
- // FFmpeg can embed cover art to FLAC
- if (coverPath != null && await File(coverPath).exists()) {
- final result = await FFmpegService.embedCover(flacPath, coverPath);
+ // Use FFmpeg to embed cover art AND text metadata
+ // FFmpeg can embed cover art to FLAC and also set tags
+
+ // Construct metadata map
+ final metadata = {
+ '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) {
- _log.d('Cover embedded via FFmpeg');
- } else {
- _log.w('FFmpeg cover embed failed');
+ if (lrcContent != null && lrcContent.isNotEmpty) {
+ metadata['LYRICS'] = lrcContent;
+ metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players
+ _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
}
-
- // Clean up cover file
+ } catch (e) {
+ _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 {
- 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 (e) {
@@ -992,7 +1089,49 @@ class DownloadQueueNotifier extends Notifier {
try {
// Get folder organization setting and build output directory
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);
+ // 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
final quality = item.qualityOverride ?? state.audioQuality;
@@ -1004,41 +1143,41 @@ class DownloadQueueNotifier extends Notifier {
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithFallback(
- isrc: item.track.isrc ?? '',
- spotifyId: item.track.id,
- trackName: item.track.name,
- artistName: item.track.artistName,
- albumName: item.track.albumName,
- albumArtist: item.track.albumArtist,
- coverUrl: item.track.coverUrl,
+ isrc: trackToDownload.isrc ?? '',
+ spotifyId: trackToDownload.id,
+ trackName: trackToDownload.name,
+ artistName: trackToDownload.artistName,
+ albumName: trackToDownload.albumName,
+ albumArtist: trackToDownload.albumArtist,
+ coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: quality,
- trackNumber: item.track.trackNumber ?? 1,
- discNumber: item.track.discNumber ?? 1,
- releaseDate: item.track.releaseDate,
+ trackNumber: trackToDownload.trackNumber ?? 1,
+ discNumber: trackToDownload.discNumber ?? 1,
+ releaseDate: trackToDownload.releaseDate,
preferredService: item.service,
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 {
result = await PlatformBridge.downloadTrack(
- isrc: item.track.isrc ?? '',
+ isrc: trackToDownload.isrc ?? '',
service: item.service,
- spotifyId: item.track.id,
- trackName: item.track.name,
- artistName: item.track.artistName,
- albumName: item.track.albumName,
- albumArtist: item.track.albumArtist,
- coverUrl: item.track.coverUrl,
+ spotifyId: trackToDownload.id,
+ trackName: trackToDownload.name,
+ artistName: trackToDownload.artistName,
+ albumName: trackToDownload.albumName,
+ albumArtist: trackToDownload.albumArtist,
+ coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: quality,
- trackNumber: item.track.trackNumber ?? 1,
- discNumber: item.track.discNumber ?? 1,
- releaseDate: item.track.releaseDate,
+ trackNumber: trackToDownload.trackNumber ?? 1,
+ discNumber: trackToDownload.discNumber ?? 1,
+ releaseDate: trackToDownload.releaseDate,
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 {
_log.i('Actual quality: $actualQuality');
}
- // Check if file is M4A (DASH stream from Tidal) and needs remuxing to FLAC
- if (filePath != null && filePath.endsWith('.m4a')) {
- _log.d('Converting M4A to FLAC...');
- updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
- final flacPath = await FFmpegService.convertM4aToFlac(filePath);
- if (flacPath != null) {
- filePath = flacPath;
- _log.d('Converted to: $flacPath');
-
- // After conversion, embed metadata and cover to the new FLAC file
- _log.d('Embedding metadata and cover to converted FLAC...');
- try {
- await _embedMetadataAndCover(
- flacPath,
- item.track,
- );
- _log.d('Metadata and cover embedded successfully');
- } catch (e) {
- _log.w('Warning: Failed to embed metadata/cover: $e');
+ // M4A files from Tidal DASH streams - try to convert to FLAC
+ // M4A files from Tidal DASH streams - try to convert to FLAC
+ if (filePath != null && filePath!.endsWith('.m4a')) {
+ _log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...');
+
+ try {
+ final file = File(filePath!);
+ if (!await file.exists()) {
+ _log.e('File does not exist at path: $filePath');
+ } else {
+ final length = await file.length();
+ _log.i('File size before conversion: ${length / 1024} KB');
+
+ if (length < 1024) {
+ _log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.');
+ } else {
+ updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95);
+ final flacPath = await FFmpegService.convertM4aToFlac(filePath!);
+
+ 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 {
);
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(
DownloadHistoryItem(
id: item.id,
- trackName: item.track.name,
- artistName: item.track.artistName,
- albumName: item.track.albumName,
+ trackName: (backendTitle != null && backendTitle.isNotEmpty) ? backendTitle : item.track.name,
+ artistName: (backendArtist != null && backendArtist.isNotEmpty) ? backendArtist : item.track.artistName,
+ albumName: (backendAlbum != null && backendAlbum.isNotEmpty) ? backendAlbum : item.track.albumName,
albumArtist: item.track.albumArtist,
coverUrl: item.track.coverUrl,
filePath: filePath,
@@ -1157,10 +1352,10 @@ class DownloadQueueNotifier extends Notifier {
// Additional metadata
isrc: item.track.isrc,
spotifyId: item.track.id,
- trackNumber: item.track.trackNumber,
- discNumber: item.track.discNumber,
+ trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
+ discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
duration: item.track.duration,
- releaseDate: item.track.releaseDate,
+ releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
quality: actualQuality,
),
);
@@ -1170,11 +1365,30 @@ class DownloadQueueNotifier extends Notifier {
}
} else {
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(
item.id,
DownloadStatus.failed,
error: errorMsg,
+ errorType: errorType,
);
_failedInSession++;
}
@@ -1191,10 +1405,22 @@ class DownloadQueueNotifier extends Notifier {
}
} catch (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(
item.id,
DownloadStatus.failed,
- error: e.toString(),
+ error: errorMsg,
+ errorType: errorType,
);
_failedInSession++;
}
diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart
index 6a616f27..082e3085 100644
--- a/lib/screens/queue_tab.dart
+++ b/lib/screens/queue_tab.dart
@@ -331,8 +331,11 @@ class _QueueTabState extends ConsumerState {
),
),
const SizedBox(width: 8),
+ // Show percentage and speed
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(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
@@ -344,7 +347,7 @@ class _QueueTabState extends ConsumerState {
if (item.status == DownloadStatus.failed) ...[
const SizedBox(height: 4),
Text(
- item.error ?? 'Download failed',
+ item.errorMessage, // Use user-friendly error message
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart
index 584efbd5..3014da53 100644
--- a/lib/screens/setup_screen.dart
+++ b/lib/screens/setup_screen.dart
@@ -23,9 +23,15 @@ class _SetupScreenState extends ConsumerState {
String? _selectedDirectory;
bool _isLoading = false;
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
- int get _totalSteps => _androidSdkVersion >= 33 ? 3 : 2;
+ // Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
+ int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
@override
void initState() {
@@ -33,6 +39,13 @@ class _SetupScreenState extends ConsumerState {
_initDeviceInfo();
}
+ @override
+ void dispose() {
+ _clientIdController.dispose();
+ _clientSecretController.dispose();
+ super.dispose();
+ }
+
Future _initDeviceInfo() async {
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
@@ -358,6 +371,23 @@ class _SetupScreenState extends ConsumerState {
}
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();
if (mounted) {
@@ -436,8 +466,8 @@ class _SetupScreenState extends ConsumerState {
Widget _buildStepIndicator(ColorScheme colorScheme) {
final steps = _androidSdkVersion >= 33
- ? ['Storage', 'Notification', 'Folder']
- : ['Permission', 'Folder'];
+ ? ['Storage', 'Notification', 'Folder', 'Spotify']
+ : ['Permission', 'Folder', 'Spotify'];
return Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -461,48 +491,61 @@ class _SetupScreenState extends ConsumerState {
Widget _buildStepDot(int step, String label, ColorScheme colorScheme) {
final isActive = _currentStep >= step;
final isCompleted = _isStepCompleted(step);
+ final isCurrent = _currentStep == step;
return Column(
children: [
- Container(
- width: 32,
- height: 32,
+ AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
+ width: 36,
+ height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted
? 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: isCompleted
- ? Icon(Icons.check, size: 18, color: colorScheme.onPrimary)
+ ? Icon(Icons.check_rounded, size: 20, color: colorScheme.onPrimary)
: Text('${step + 1}',
style: TextStyle(
- color: isActive ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
- fontWeight: FontWeight.bold)),
+ color: isCurrent ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
+ fontSize: 14,
+ )),
),
),
- const SizedBox(height: 4),
+ const SizedBox(height: 6),
Text(label,
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) {
if (_androidSdkVersion >= 33) {
- // 3 steps: Storage, Notification, Folder
+ // 4 steps: Storage, Notification, Folder, Spotify
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _notificationPermissionGranted;
case 2: return _selectedDirectory != null;
+ case 3: return false; // Spotify step never shows checkmark (optional)
}
} else {
- // 2 steps: Permission, Folder
+ // 3 steps: Permission, Folder, Spotify
switch (step) {
case 0: return _storagePermissionGranted;
case 1: return _selectedDirectory != null;
+ case 2: return false; // Spotify step never shows checkmark (optional)
}
}
return false;
@@ -514,11 +557,13 @@ class _SetupScreenState extends ConsumerState {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildNotificationPermissionStep(colorScheme);
case 2: return _buildDirectoryStep(colorScheme);
+ case 3: return _buildSpotifyApiStep(colorScheme);
}
} else {
switch (_currentStep) {
case 0: return _buildStoragePermissionStep(colorScheme);
case 1: return _buildDirectoryStep(colorScheme);
+ case 2: return _buildSpotifyApiStep(colorScheme);
}
}
return const SizedBox();
@@ -529,35 +574,50 @@ class _SetupScreenState extends ConsumerState {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
- Icon(
- _storagePermissionGranted ? Icons.check_circle : Icons.folder_open,
- size: 56,
- color: _storagePermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
+ // Icon with container background (M3 style)
+ Container(
+ width: 80,
+ 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(
_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,
),
const SizedBox(height: 8),
- Text(
- _storagePermissionGranted
- ? 'You can now proceed to the next step.'
- : '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,
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Text(
+ _storagePermissionGranted
+ ? 'You can now proceed to the next step.'
+ : '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)
FilledButton.icon(
onPressed: _isLoading ? null : _requestStoragePermission,
icon: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
- : const Icon(Icons.security),
+ : const Icon(Icons.security_rounded),
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 {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
- Icon(
- _notificationPermissionGranted ? Icons.check_circle : Icons.notifications_outlined,
- size: 56,
- color: _notificationPermissionGranted ? colorScheme.primary : colorScheme.onSurfaceVariant,
+ // Icon with container background (M3 style)
+ Container(
+ width: 80,
+ 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(
_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,
),
const SizedBox(height: 8),
- Text(
- _notificationPermissionGranted
- ? 'You will receive download progress notifications.'
- : '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,
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Text(
+ _notificationPermissionGranted
+ ? 'You will receive download progress notifications.'
+ : '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) ...[
FilledButton.icon(
onPressed: _isLoading ? null : _requestNotificationPermission,
icon: _isLoading
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
- : const Icon(Icons.notifications_active),
+ : const Icon(Icons.notifications_active_rounded),
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),
TextButton(
onPressed: _skipNotificationPermission,
+ style: TextButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
+ ),
child: const Text('Skip for now'),
),
],
@@ -613,51 +691,226 @@ class _SetupScreenState extends ConsumerState {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
- Icon(
- _selectedDirectory != null ? Icons.folder : Icons.create_new_folder,
- size: 56,
- color: _selectedDirectory != null ? colorScheme.primary : colorScheme.onSurfaceVariant,
+ // Icon with container background (M3 style)
+ Container(
+ width: 80,
+ 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(
_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,
),
const SizedBox(height: 8),
if (_selectedDirectory != null)
- Container(
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(Icons.folder, color: colorScheme.primary, size: 20),
- const SizedBox(width: 8),
- Flexible(
- child: Text(_selectedDirectory!,
- style: Theme.of(context).textTheme.bodySmall,
- overflow: TextOverflow.ellipsis),
- ),
- ],
+ Card(
+ elevation: 0,
+ color: colorScheme.surfaceContainerHigh,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.folder_rounded, color: colorScheme.primary, size: 20),
+ const SizedBox(width: 12),
+ Flexible(
+ child: Text(
+ _selectedDirectory!,
+ style: Theme.of(context).textTheme.bodySmall,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
),
)
else
- Text('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: 20),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Text(
+ '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(
onPressed: _isLoading ? null : _selectDirectory,
icon: _isLoading
? SizedBox(width: 20, height: 20,
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'),
- 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 {
Widget _buildNavigationButtons(ColorScheme colorScheme) {
final isLastStep = _currentStep == _totalSteps - 1;
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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -674,8 +931,11 @@ class _SetupScreenState extends ConsumerState {
if (_currentStep > 0)
TextButton.icon(
onPressed: () => setState(() => _currentStep--),
- icon: const Icon(Icons.arrow_back),
+ icon: const Icon(Icons.arrow_back_rounded),
label: const Text('Back'),
+ style: TextButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ ),
)
else
const SizedBox(width: 100),
@@ -684,20 +944,32 @@ class _SetupScreenState extends ConsumerState {
if (!isLastStep)
FilledButton(
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(
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
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
? SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
- : const Row(
+ : Row(
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),
+ ],
),
),
],
diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart
index f5aa91a6..f7878bec 100644
--- a/lib/services/ffmpeg_service.dart
+++ b/lib/services/ffmpeg_service.dart
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/services.dart';
+import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
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
- static Future embedCover(String flacPath, String coverPath) async {
- final tempOutput = '$flacPath.tmp';
- final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
+ static Future embedMetadata({
+ required String flacPath,
+ String? coverPath,
+ Map? 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);
if (result.success) {
try {
- // Replace original with temp
- await File(flacPath).delete();
- await File(tempOutput).rename(flacPath);
- return flacPath;
+ // Copy temp output back to original location (replace)
+ final tempFile = File(tempOutput);
+ final originalFile = File(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) {
- _log.e('Failed to replace file after cover embed: $e');
+ _log.e('Failed to replace file after metadata embed: $e');
return null;
}
}
@@ -161,7 +223,7 @@ class FFmpegService {
}
} catch (_) {}
- _log.e('Cover embed failed: ${result.output}');
+ _log.e('Metadata/Cover embed failed: ${result.output}');
return null;
}
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 0b70217d..b1269c68 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
-version: 2.1.5+43
+version: 2.1.6+44
environment:
sdk: ^3.10.0
diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml
index 6d6715e7..4c95d21f 100644
--- a/pubspec_ios.yaml
+++ b/pubspec_ios.yaml
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
-version: 2.1.0-preview2+40
+version: 2.1.6+44
environment:
sdk: ^3.10.0