Fix metadata consistency (Go->Flutter) and build optimization

- Backend: Return full metadata (Track, Disc, Year) from Tidal/Qobuz/Amazon download results
- Flutter: Use backend metadata for tagging converted M4A and history entries
- Fix: Duplicate convertTrack method in deezer.go
- Fix: Better error message for Deezer fallback failure
- Changed: Default service fallback to Tidal -> Qobuz -> Amazon
- Build: Re-enabled resource shrinking and minification for release build
This commit is contained in:
zarzet
2026-01-09 02:04:01 +07:00
parent ee807a44cc
commit 01b8fd2480
31 changed files with 3085 additions and 475 deletions
+152 -11
View File
@@ -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
+13 -8
View File
@@ -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")
}
Binary file not shown.
Binary file not shown.
+129 -19
View File
@@ -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
}
+150 -53
View File
@@ -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
+222 -32
View File
@@ -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)
}
}
+178 -23
View File
@@ -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
+49 -15
View File
@@ -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
+41
View File
@@ -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\..*?\)`,
+390 -33
View File
@@ -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")
}
+2 -1
View File
@@ -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")
+37 -3
View File
@@ -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
}
+89 -16
View File
@@ -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
}
+222
View File
@@ -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)
}
+262 -2
View File
@@ -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
}
+110 -23
View File
@@ -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,
)
}
+245 -64
View File
@@ -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(`<S d="\d+"(?: r="(\d+)")?`)
fmt.Println("[Tidal] No segments from XML, trying regex...")
// Match <S d="..." /> or <S d="..." r="..." />
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
for _, match := range matches {
fmt.Printf("[Tidal] Regex found %d segment entries\n", len(matches))
for i, match := range matches {
repeat := 0
if len(match) > 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
}
+2 -2
View File
@@ -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';
+32
View File
@@ -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<String, dynamic> json) =>
_$DownloadItemFromJson(json);
Map<String, dynamic> toJson() => _$DownloadItemToJson(this);
+11
View File
@@ -14,8 +14,10 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> 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<String, dynamic> _$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',
};
+1 -1
View File
@@ -7,7 +7,7 @@ part of 'settings.dart';
// **************************************************************************
AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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? ?? '',
+8
View File
@@ -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<String, dynamic> json) =>
+8
View File
@@ -18,6 +18,7 @@ Track _$TrackFromJson(Map<String, dynamic> 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<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'trackNumber': instance.trackNumber,
'discNumber': instance.discNumber,
'releaseDate': instance.releaseDate,
'deezerId': instance.deezerId,
'availability': instance.availability,
};
@@ -45,9 +47,12 @@ ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> 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<String, dynamic> _$ServiceAvailabilityToJson(
@@ -56,7 +61,10 @@ Map<String, dynamic> _$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,
};
+302 -76
View File
@@ -371,6 +371,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final itemProgress = entry.value as Map<String, dynamic>;
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<DownloadQueueState> {
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<DownloadQueueState> {
? 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<DownloadQueueState> {
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<DownloadQueueState> {
}
}
void updateItemStatus(String id, DownloadStatus status, {double? progress, String? filePath, String? error}) {
void updateItemStatus(String id, DownloadStatus status, {double? progress, double? speedMBps, String? filePath, String? error, DownloadErrorType? errorType}) {
final items = state.items.map((item) {
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<DownloadQueueState> {
}
}
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<DownloadQueueState> {
// 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<DownloadQueueState> {
// 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 = <String, String>{
'TITLE': track.name,
'ARTIST': track.artistName,
'ALBUM': track.albumName,
};
if (track.albumArtist != null) {
metadata['ALBUMARTIST'] = track.albumArtist!;
}
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString(); // Compatibility
}
if (track.discNumber != null) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString(); // Compatibility
}
if (track.releaseDate != null) {
metadata['DATE'] = track.releaseDate!;
metadata['YEAR'] = track.releaseDate!.split('-').first;
}
if (track.isrc != null) {
metadata['ISRC'] = track.isrc!;
}
// Fetch Lyrics (Critical for M4A->FLAC conversion parity)
// Since we are in the Flutter context, we can call the bridge to get lyrics
// This ensures even converted files have lyrics embedded if available
try {
final lrcContent = await PlatformBridge.getLyricsLRC(
track.id, // spotifyID
track.name,
track.artistName,
filePath: '', // No local file path yet (processed in memory)
);
if (result != null) {
_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<DownloadQueueState> {
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<String, dynamic>);
// Merge with existing (keep override quality/service if any, but update metadata)
trackToDownload = Track(
id: fullTrack.id.isNotEmpty ? fullTrack.id : trackToDownload.id,
name: fullTrack.name,
artistName: fullTrack.artistName,
albumName: fullTrack.albumName,
albumArtist: fullTrack.albumArtist,
coverUrl: fullTrack.coverUrl,
duration: fullTrack.duration,
isrc: fullTrack.isrc ?? trackToDownload.isrc,
trackNumber: fullTrack.trackNumber,
discNumber: fullTrack.discNumber,
releaseDate: fullTrack.releaseDate,
deezerId: fullTrack.deezerId,
availability: trackToDownload.availability,
);
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, Year ${trackToDownload.releaseDate}');
// Update item in state with enriched track
// This is important so the UI (and history) reflects the enriched data
// We don't perform a full `updateItemStatus` here to avoid UI flicker, just local var
}
}
} catch (e) {
_log.w('Failed to enrich metadata: $e');
}
}
final outputDir = await _buildOutputDir(trackToDownload, settings.folderOrganization);
// Use quality override if set, otherwise use default from settings
final quality = item.qualityOverride ?? state.audioQuality;
@@ -1004,41 +1143,41 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
_log.d('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<DownloadQueueState> {
_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<DownloadQueueState> {
);
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<DownloadQueueState> {
// 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<DownloadQueueState> {
}
} 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<DownloadQueueState> {
}
} 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++;
}
+5 -2
View File
@@ -331,8 +331,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
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<QueueTab> {
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(
+351 -79
View File
@@ -23,9 +23,15 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
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<SetupScreen> {
_initDeviceInfo();
}
@override
void dispose() {
_clientIdController.dispose();
_clientSecretController.dispose();
super.dispose();
}
Future<void> _initDeviceInfo() async {
if (Platform.isAndroid) {
final deviceInfo = DeviceInfoPlugin();
@@ -358,6 +371,23 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
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<SetupScreen> {
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<SetupScreen> {
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<SetupScreen> {
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<SetupScreen> {
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<SetupScreen> {
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<SetupScreen> {
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<SetupScreen> {
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<SetupScreen> {
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<SetupScreen> {
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),
],
),
),
],
+72 -10
View File
@@ -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<String?> embedCover(String flacPath, String coverPath) async {
final tempOutput = '$flacPath.tmp';
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
static Future<String?> embedMetadata({
required String flacPath,
String? coverPath,
Map<String, String>? metadata,
}) async {
// Android Scoped Storage: Cannot write directly to Music folder with FFmpeg
// Use app-internal cache directory for temp output
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
// Construct command
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" ');
// Add cover input if available
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
// Map audio stream
cmdBuffer.write('-map 0:a ');
// Map cover stream if available
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v copy ');
cmdBuffer.write('-disposition:v attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
// Copy audio codec (don't re-encode)
cmdBuffer.write('-c:a copy ');
// Add text metadata
if (metadata != null) {
metadata.forEach((key, value) {
// Sanitize value: escape double quotes
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg command: $command');
final result = await _execute(command);
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;
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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