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