From bea5dd1d4acd582b2bce67324f9f144e07984a8e Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 10 Jan 2026 04:32:47 +0700 Subject: [PATCH] v2.2.0: Default to Tidal, faster ISRC matching, ISRC enrichment for search --- .github/workflows/release.yml | 7 +- CHANGELOG.md | 58 ++++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 7 + go_backend/amazon.go | 98 ++++--- go_backend/exports.go | 262 +++++++++++------- go_backend/metadata.go | 28 +- go_backend/qobuz.go | 61 ++-- go_backend/tidal.go | 152 ++++++---- ios/Runner/AppDelegate.swift | 7 + lib/constants/app_info.dart | 4 +- lib/models/settings.dart | 2 +- lib/providers/download_queue_provider.dart | 105 ++++--- lib/screens/track_metadata_screen.dart | 123 ++++---- lib/services/platform_bridge.dart | 15 +- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 16 files changed, 621 insertions(+), 312 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b7e8ad..cea22f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -382,20 +382,17 @@ jobs: ### Downloads #### Android - ![Android arm64 Downloads](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=for-the-badge&logo=android&label=arm64&color=3DDC84) - ![Android arm32 Downloads](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=for-the-badge&logo=android&label=arm32&color=3DDC84) - - **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices) - **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices) #### iOS - ![iOS Downloads](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=for-the-badge&logo=apple&label=iOS&color=0078D6) - - **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required) ### Installation **Android**: Enable "Install from unknown sources" and install the APK **iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA + + ![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6) FOOTER echo "Release body:" diff --git a/CHANGELOG.md b/CHANGELOG.md index d272aac..1155cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +## [2.2.0] - 2026-01-10 + +### Fixed + +- **ISRC Metadata Missing:** Fixed an issue where ISRC codes were not being saved to the download history or embedded in file metadata for certain downloads. The backend now correctly propagates the ISRC found from streaming services (Tidal, Qobuz, Amazon) back to the application. +- **Tidal Track/Disc Numbers:** Fixed missing Track Number and Disc Number in Tidal downloads. The downloader now prioritizes the actual metadata returned by Tidal over the potentially incomplete metadata from the initial search request. +- **Concurrent Download Race Condition:** Fixed a potential race condition where temporary cover art files could overwrite each other during rapid concurrent downloads by adding randomization to temporary filenames. +- **Qobuz Search Accuracy:** Reduced the duration tolerance for Qobuz search matches from 30s to 10s to prevent matching with incorrect versions/remixes. +- **Metadata Enrichment Null Safety**: Fixed `type 'Null' is not a subtype of type 'String'` error + - Added proper null checks when parsing Go backend response + - Added type checking for track data before parsing +- **Duration Calculation in Enrichment**: Fixed duration conversion bug + - Go backend returns `duration_ms` (milliseconds) + - Now properly converts to seconds for Track model + +### Changed + +- **Default Service Priority:** Updated the default download fallback order to **Tidal → Qobuz → Amazon**. + - Tidal is now the default download service (was Qobuz) + - Tidal has faster and more reliable ISRC matching + - Existing users need to change setting manually or clear app data +- **Metadata Enrichment:** Improved metadata handling for Deezer tracks. If critical metadata (ISRC, Track Number) is missing from the initial search, the app now automatically fetches full details from the Deezer API before finding a source. + +### Added + +- **ISRC in History:** The Download History now reliably displays the ISRC code for downloaded tracks. +- **Tidal Search Optimization:** Optimized Tidal search logic to immediately check for ISRC matches within search results, improving match speed and accuracy. + - Returns as soon as ISRC match is found in first query results + - Significantly faster for tracks with valid ISRC +- **ISRC Enrichment for Search Results**: Tracks from Home search now fetch ISRC before download + - Search results don't include ISRC (for performance) + - ISRC is now fetched via metadata enrichment when download starts + - Ensures accurate track matching on all streaming services +- **Deezer-to-Tidal Fallback:** Added native support for converting Deezer IDs to Tidal links via SongLink when using the fallback mechanism. +- **Better Logging for Qobuz ISRC Search**: Added detailed logs for debugging + - Shows when ISRC search is attempted + - Shows number of results and exact ISRC matches found + +### Technical + +- Updated `go_backend/tidal.go`: + - Early exit optimization in `SearchTrackByMetadataWithISRC()` + - Deezer ID support in SongLink lookup +- Updated `go_backend/qobuz.go`: + - Added logging for ISRC search flow + - Duration tolerance reduced from 30s to 10s +- Updated `go_backend/exports.go`: + - Default service order changed to `[tidal, qobuz, amazon]` +- Updated `lib/providers/download_queue_provider.dart`: + - ISRC-based enrichment condition + - Null-safe parsing of Go backend response +- Updated `lib/services/platform_bridge.dart`: + - Null check for `getDeezerMetadata` result +- Updated `lib/models/settings.dart`: + - Default service changed to `tidal` + +--- + ## [2.1.7] - 2026-01-09 ### Added diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index f22ca49..1302857 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -180,6 +180,13 @@ class MainActivity: FlutterActivity() { } result.success(null) } + "readFileMetadata" -> { + val filePath = call.argument("file_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.readFileMetadata(filePath) + } + result.success(response) + } "startDownloadService" -> { val trackName = call.argument("track_name") ?: "" val artistName = call.argument("artist_name") ?: "" diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 06def6f..1e70cf7 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -18,7 +18,7 @@ import ( // AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC) type AmazonDownloader struct { client *http.Client - regions []string // us, eu regions for DoubleDouble service + regions []string // us, eu regions for DoubleDouble service lastAPICallTime time.Time // Rate limiting: track last API call apiCallCount int // Rate limiting: counter per minute apiCallResetTime time.Time // Rate limiting: reset time @@ -52,37 +52,37 @@ type DoubleDoubleStatusResponse struct { func amazonArtistsMatch(expectedArtist, foundArtist string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist)) - + // Exact match if normExpected == normFound { return true } - + // Check if one contains the other if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { return true } - + // Check first artist (before comma or feat) expectedFirst := strings.Split(normExpected, ",")[0] expectedFirst = strings.Split(expectedFirst, " feat")[0] expectedFirst = strings.Split(expectedFirst, " ft.")[0] expectedFirst = strings.TrimSpace(expectedFirst) - + foundFirst := strings.Split(normFound, ",")[0] foundFirst = strings.Split(foundFirst, " feat")[0] foundFirst = strings.Split(foundFirst, " ft.")[0] foundFirst = strings.TrimSpace(foundFirst) - + if expectedFirst == foundFirst { return true } - + // Check if first artist is contained in the other if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { return true } - + // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), // assume they're the same artist with different transliteration expectedASCII := amazonIsASCIIString(expectedArtist) @@ -91,7 +91,7 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool { fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) return true } - + return false } @@ -124,7 +124,7 @@ func (a *AmazonDownloader) waitForRateLimit() { defer amazonRateLimitMu.Unlock() now := time.Now() - + // Reset counter every minute if now.Sub(a.apiCallResetTime) >= time.Minute { a.apiCallCount = 0 @@ -170,7 +170,6 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string { return apis } - // downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC) // This uses submit → poll → download mechanism // Internal function - not exported to gomobile @@ -182,7 +181,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir // Build base URL for DoubleDouble service // Decode base64 service URL (same as PC) - serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https:// + serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https:// serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) @@ -202,7 +201,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir req.Header.Set("User-Agent", getRandomUserAgent()) fmt.Println("[Amazon] Submitting download request...") - + // Retry logic for 429 errors (like PC version: 3 retries with 15s wait) var resp *http.Response maxRetries := 3 @@ -345,7 +344,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) } - // DownloadFile downloads a file from URL with User-Agent and progress tracking func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { // Initialize item progress (required for all downloads) @@ -434,6 +432,7 @@ type AmazonDownloadResult struct { ReleaseDate string TrackNumber int DiscNumber int + ISRC string } // downloadFromAmazon downloads a track using the request parameters @@ -450,7 +449,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { songlink := NewSongLinkClient() 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 @@ -463,7 +462,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } 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) } @@ -546,16 +545,35 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName) } + // Read existing metadata from downloaded file BEFORE embedding + // Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve + existingMeta, metaErr := ReadMetadata(outputPath) + actualTrackNum := req.TrackNumber + actualDiscNum := req.DiscNumber + + if metaErr == nil && existingMeta != nil { + // Use file metadata if it has valid track/disc numbers and request doesn't have them + if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { + actualTrackNum = existingMeta.TrackNumber + fmt.Printf("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) + } + if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) { + actualDiscNum = existingMeta.DiscNumber + fmt.Printf("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) + } + } + // Embed metadata using Spotify data (more accurate than DoubleDouble) + // But preserve track/disc numbers from file if they were better metadata := Metadata{ Title: req.TrackName, Artist: req.ArtistName, Album: req.AlbumName, AlbumArtist: req.AlbumArtist, Date: req.ReleaseDate, - TrackNumber: req.TrackNumber, + TrackNumber: actualTrackNum, TotalTracks: req.TotalTracks, - DiscNumber: req.DiscNumber, + DiscNumber: actualDiscNum, ISRC: req.ISRC, } @@ -583,36 +601,50 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music") - + // Read actual quality from the downloaded FLAC file // Amazon API doesn't provide quality info, but we can read it from the file itself quality, err := GetAudioQuality(outputPath) if err != nil { fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err) - // Add to ISRC index for fast duplicate checking - AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) - // Return 0 to indicate unknown quality - return AmazonDownloadResult{ - FilePath: outputPath, - BitDepth: 0, - SampleRate: 0, - }, nil + } else { + fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + } + + // Read metadata from file AFTER embedding to get accurate values + // This ensures we return what's actually in the file + finalMeta, metaReadErr := ReadMetadata(outputPath) + if metaReadErr == nil && finalMeta != nil { + fmt.Printf("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", + finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) + actualTrackNum = finalMeta.TrackNumber + actualDiscNum = finalMeta.DiscNumber + if finalMeta.Date != "" { + // Use date from file if available + req.ReleaseDate = finalMeta.Date + } } - - 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) + bitDepth := 0 + sampleRate := 0 + if err == nil { + bitDepth = quality.BitDepth + sampleRate = quality.SampleRate + } + return AmazonDownloadResult{ FilePath: outputPath, - BitDepth: quality.BitDepth, - SampleRate: quality.SampleRate, + BitDepth: bitDepth, + SampleRate: sampleRate, Title: req.TrackName, Artist: req.ArtistName, Album: req.AlbumName, ReleaseDate: req.ReleaseDate, - TrackNumber: req.TrackNumber, - DiscNumber: req.DiscNumber, + TrackNumber: actualTrackNum, + DiscNumber: actualDiscNum, + ISRC: req.ISRC, }, nil } diff --git a/go_backend/exports.go b/go_backend/exports.go index 249ac1b..5ef232e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -17,17 +17,17 @@ func ParseSpotifyURL(url string) (string, error) { if err != nil { return "", err } - + result := map[string]string{ "type": parsed.Type, "id": parsed.ID, } - + jsonBytes, err := json.Marshal(result) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -42,18 +42,18 @@ func SetSpotifyAPICredentials(clientID, clientSecret string) { func GetSpotifyMetadata(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - + client := NewSpotifyMetadataClient() data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) if err != nil { return "", err } - + jsonBytes, err := json.Marshal(data) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -62,18 +62,18 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) { func SearchSpotify(query string, limit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - + client := NewSpotifyMetadataClient() results, err := client.SearchTracks(ctx, query, limit) if err != nil { return "", err } - + jsonBytes, err := json.Marshal(results) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -82,18 +82,18 @@ func SearchSpotify(query string, limit int) (string, error) { func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - + client := NewSpotifyMetadataClient() results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) if err != nil { return "", err } - + jsonBytes, err := json.Marshal(results) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -105,12 +105,12 @@ func CheckAvailability(spotifyID, isrc string) (string, error) { if err != nil { return "", err } - + jsonBytes, err := json.Marshal(availability) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -133,7 +133,7 @@ type DownloadRequest struct { DiscNumber int `json:"disc_number"` TotalTracks int `json:"total_tracks"` ReleaseDate string `json:"release_date"` - ItemID string `json:"item_id"` // Unique ID for progress tracking + ItemID string `json:"item_id"` // Unique ID for progress tracking DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) } @@ -155,6 +155,7 @@ type DownloadResponse struct { ReleaseDate string `json:"release_date,omitempty"` TrackNumber int `json:"track_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"` + ISRC string `json:"isrc,omitempty"` } // DownloadResult is a generic result type for all downloaders @@ -169,6 +170,7 @@ type DownloadResult struct { ReleaseDate string TrackNumber int DiscNumber int + ISRC string } // DownloadTrack downloads a track from the specified service @@ -179,17 +181,17 @@ func DownloadTrack(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } - + // Trim whitespace from string fields to prevent filename/path issues req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) - + var result DownloadResult var err error - + switch req.Service { case "tidal": tidalResult, tidalErr := downloadFromTidal(req) @@ -204,6 +206,7 @@ func DownloadTrack(requestJSON string) (string, error) { ReleaseDate: tidalResult.ReleaseDate, TrackNumber: tidalResult.TrackNumber, DiscNumber: tidalResult.DiscNumber, + ISRC: tidalResult.ISRC, } } err = tidalErr @@ -220,6 +223,7 @@ func DownloadTrack(requestJSON string) (string, error) { ReleaseDate: qobuzResult.ReleaseDate, TrackNumber: qobuzResult.TrackNumber, DiscNumber: qobuzResult.DiscNumber, + ISRC: qobuzResult.ISRC, } } err = qobuzErr @@ -236,17 +240,18 @@ func DownloadTrack(requestJSON string) (string, error) { ReleaseDate: amazonResult.ReleaseDate, TrackNumber: amazonResult.TrackNumber, DiscNumber: amazonResult.DiscNumber, + ISRC: amazonResult.ISRC, } } err = amazonErr default: return errorResponse("Unknown service: " + req.Service) } - + if err != nil { return errorResponse(err.Error()) } - + // Check if file already exists if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { actualPath := result.FilePath[7:] @@ -264,11 +269,18 @@ 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, + ISRC: result.ISRC, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } - + // Read actual quality from downloaded file (more accurate than API) quality, qErr := GetAudioQuality(result.FilePath) if qErr == nil { @@ -278,7 +290,7 @@ func DownloadTrack(requestJSON string) (string, error) { } else { fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) } - + resp := DownloadResponse{ Success: true, Message: "Download complete", @@ -292,8 +304,9 @@ func DownloadTrack(requestJSON string) (string, error) { ReleaseDate: result.ReleaseDate, TrackNumber: result.TrackNumber, DiscNumber: result.DiscNumber, + ISRC: result.ISRC, } - + jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } @@ -305,23 +318,23 @@ func DownloadWithFallback(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } - + // Trim whitespace from string fields to prevent filename/path issues req.TrackName = strings.TrimSpace(req.TrackName) req.ArtistName = strings.TrimSpace(req.ArtistName) req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) - + // Build service order starting with preferred service - allServices := []string{"qobuz", "tidal", "amazon"} + allServices := []string{"tidal", "qobuz", "amazon"} preferredService := req.Service if preferredService == "" { preferredService = "tidal" } - + fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service) - + // Create ordered list: preferred first, then others services := []string{preferredService} for _, s := range allServices { @@ -329,18 +342,18 @@ func DownloadWithFallback(requestJSON string) (string, error) { services = append(services, s) } } - + fmt.Printf("[DownloadWithFallback] Service order: %v\n", services) - + var lastErr error - + for _, service := range services { fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service) req.Service = service - + var result DownloadResult var err error - + switch service { case "tidal": tidalResult, tidalErr := downloadFromTidal(req) @@ -355,6 +368,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { ReleaseDate: tidalResult.ReleaseDate, TrackNumber: tidalResult.TrackNumber, DiscNumber: tidalResult.DiscNumber, + ISRC: tidalResult.ISRC, } } else { fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr) @@ -364,9 +378,16 @@ func DownloadWithFallback(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, + ISRC: qobuzResult.ISRC, } } else { fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) @@ -385,13 +406,14 @@ func DownloadWithFallback(requestJSON string) (string, error) { ReleaseDate: amazonResult.ReleaseDate, TrackNumber: amazonResult.TrackNumber, DiscNumber: amazonResult.DiscNumber, + ISRC: amazonResult.ISRC, } } else { fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr) } err = amazonErr } - + if err == nil { // Check if file already exists if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { @@ -410,11 +432,18 @@ func DownloadWithFallback(requestJSON string) (string, error) { ActualBitDepth: result.BitDepth, ActualSampleRate: result.SampleRate, Service: service, + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + ReleaseDate: result.ReleaseDate, + TrackNumber: result.TrackNumber, + DiscNumber: result.DiscNumber, + ISRC: result.ISRC, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } - + // Read actual quality from downloaded file (more accurate than API) quality, qErr := GetAudioQuality(result.FilePath) if qErr == nil { @@ -424,7 +453,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { } else { fmt.Printf("[Download] Could not read quality from file: %v\n", qErr) } - + resp := DownloadResponse{ Success: true, Message: "Downloaded from " + service, @@ -432,14 +461,21 @@ func DownloadWithFallback(requestJSON string) (string, error) { ActualBitDepth: result.BitDepth, ActualSampleRate: result.SampleRate, Service: service, + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + ReleaseDate: result.ReleaseDate, + TrackNumber: result.TrackNumber, + DiscNumber: result.DiscNumber, + ISRC: result.ISRC, } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } - + lastErr = err } - + return errorResponse("All services failed. Last error: " + lastErr.Error()) } @@ -477,6 +513,44 @@ func CleanupConnections() { CloseIdleConnections() } +// ReadFileMetadata reads metadata directly from a FLAC file +// Returns JSON with all embedded metadata (title, artist, album, track number, etc.) +// This is useful for displaying accurate metadata in the UI without relying on cached data +func ReadFileMetadata(filePath string) (string, error) { + metadata, err := ReadMetadata(filePath) + if err != nil { + return "", fmt.Errorf("failed to read metadata: %w", err) + } + + // Also get audio quality info + quality, qualityErr := GetAudioQuality(filePath) + + result := map[string]interface{}{ + "title": metadata.Title, + "artist": metadata.Artist, + "album": metadata.Album, + "album_artist": metadata.AlbumArtist, + "date": metadata.Date, + "track_number": metadata.TrackNumber, + "disc_number": metadata.DiscNumber, + "isrc": metadata.ISRC, + "lyrics": metadata.Lyrics, + } + + // Add quality info if available + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + // SetDownloadDirectory sets the default download directory func SetDownloadDirectory(path string) error { return setDownloadDir(path) @@ -485,17 +559,17 @@ func SetDownloadDirectory(path string) error { // CheckDuplicate checks if a file with the given ISRC exists func CheckDuplicate(outputDir, isrc string) (string, error) { existingFile, exists := CheckISRCExists(outputDir, isrc) - + result := map[string]interface{}{ "exists": exists, "filepath": existingFile, } - + jsonBytes, err := json.Marshal(result) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -525,7 +599,7 @@ func BuildFilename(template string, metadataJSON string) (string, error) { if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { return "", err } - + filename := buildFilenameFromTemplate(template, metadata) return filename, nil } @@ -655,18 +729,18 @@ func ClearTrackIDCache() { func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - + client := GetDeezerClient() results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) if err != nil { return "", err } - + jsonBytes, err := json.Marshal(results) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -676,11 +750,11 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) func GetDeezerMetadata(resourceType, resourceID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - + client := GetDeezerClient() var data interface{} var err error - + switch resourceType { case "track": data, err = client.GetTrack(ctx, resourceID) @@ -693,16 +767,16 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) { default: return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType) } - + if err != nil { return "", err } - + jsonBytes, err := json.Marshal(data) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -712,17 +786,17 @@ func ParseDeezerURLExport(url string) (string, error) { if err != nil { return "", err } - + result := map[string]string{ "type": resourceType, "id": resourceID, } - + jsonBytes, err := json.Marshal(result) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -730,18 +804,18 @@ func ParseDeezerURLExport(url string) (string, error) { func SearchDeezerByISRC(isrc string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - + client := GetDeezerClient() track, err := client.SearchByISRC(ctx, isrc) if err != nil { return "", err } - + jsonBytes, err := json.Marshal(track) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -751,52 +825,52 @@ func SearchDeezerByISRC(isrc string) (string, error) { func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - + songlink := NewSongLinkClient() deezerClient := GetDeezerClient() - + // For tracks, we can use SongLink to get Deezer ID if resourceType == "track" { deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID) if err != nil { return "", fmt.Errorf("could not find Deezer equivalent: %w", err) } - + // Fetch metadata from Deezer trackResp, err := deezerClient.GetTrack(ctx, deezerID) if err != nil { return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err) } - + jsonBytes, err := json.Marshal(trackResp) if err != nil { return "", err } - + return string(jsonBytes), nil } - + // For albums, SongLink also provides mapping if resourceType == "album" { deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID) if err != nil { return "", fmt.Errorf("could not find Deezer album: %w", err) } - + // Fetch album metadata from Deezer albumResp, err := deezerClient.GetAlbum(ctx, deezerID) if err != nil { return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err) } - + jsonBytes, err := json.Marshal(albumResp) if err != nil { return "", err } - + return string(jsonBytes), nil } - + // For artists/playlists, SongLink doesn't provide direct mapping return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType) } @@ -805,7 +879,7 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - + // Try Spotify first client := NewSpotifyMetadataClient() data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) @@ -816,32 +890,32 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { } return string(jsonBytes), nil } - + // Check if it's a rate limit error errStr := strings.ToLower(err.Error()) if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") { // Not a rate limit error, return original error return "", err } - + // Rate limited - try Deezer fallback for tracks and albums parsed, parseErr := parseSpotifyURI(spotifyURL) if parseErr != nil { return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr) } - + fmt.Printf("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type) - + if parsed.Type == "track" || parsed.Type == "album" { // Convert to Deezer return ConvertSpotifyToDeezer(parsed.Type, parsed.ID) } - + // Artist and playlist not supported for fallback if parsed.Type == "artist" { return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later") } - + return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API") } @@ -855,12 +929,12 @@ func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { if err != nil { return "", err } - + jsonBytes, err := json.Marshal(availability) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -874,12 +948,12 @@ func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (strin if err != nil { return "", err } - + jsonBytes, err := json.Marshal(availability) if err != nil { return "", err } - + return string(jsonBytes), nil } @@ -905,24 +979,24 @@ 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") { + + 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") { + } 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") { + } 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, diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 1816175..ccfa64f 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -257,11 +257,30 @@ func ReadMetadata(filePath string) (*Metadata, error) { if trackNum != "" { fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) } + // Also try lowercase variant (some encoders use lowercase) + if metadata.TrackNumber == 0 { + trackNum = getComment(cmt, "TRACK") + if trackNum != "" { + fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) + } + } discNum := getComment(cmt, "DISCNUMBER") if discNum != "" { fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) } + // Also try DISC variant + if metadata.DiscNumber == 0 { + discNum = getComment(cmt, "DISC") + if discNum != "" { + fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) + } + } + + // Try DATE variants + if metadata.Date == "" { + metadata.Date = getComment(cmt, "YEAR") + } break } @@ -291,9 +310,14 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) { } func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string { + keyUpper := strings.ToUpper(key) + "=" for _, comment := range cmt.Comments { - if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" { - return comment[len(key)+1:] + if len(comment) > len(key) { + // Case-insensitive comparison for Vorbis comments + commentUpper := strings.ToUpper(comment[:len(key)+1]) + if commentUpper == keyUpper { + return comment[len(key)+1:] + } } } return "" diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index a9f8c19..3be5dbd 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -52,37 +52,37 @@ type QobuzTrack struct { func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist)) - + // Exact match if normExpected == normFound { return true } - + // Check if one contains the other if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { return true } - + // Check first artist (before comma or feat) expectedFirst := strings.Split(normExpected, ",")[0] expectedFirst = strings.Split(expectedFirst, " feat")[0] expectedFirst = strings.Split(expectedFirst, " ft.")[0] expectedFirst = strings.TrimSpace(expectedFirst) - + foundFirst := strings.Split(normFound, ",")[0] foundFirst = strings.Split(foundFirst, " feat")[0] foundFirst = strings.Split(foundFirst, " ft.")[0] foundFirst = strings.TrimSpace(foundFirst) - + if expectedFirst == foundFirst { return true } - + // Check if first artist is contained in the other if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { return true } - + // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), // assume they're the same artist with different transliteration expectedASCII := qobuzIsASCIIString(expectedArtist) @@ -91,7 +91,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) return true } - + return false } @@ -132,8 +132,8 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string { // Same APIs as PC version (referensi/backend/qobuz.go) // Primary: dab.yeet.su, Fallback: dabmusic.xyz encodedAPIs := []string{ - "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC) - "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC) + "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC) + "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC) } var apis []string @@ -194,6 +194,8 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { // SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification // expectedDurationSec is the expected duration in seconds (0 to skip verification) func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { + fmt.Printf("[Qobuz] Searching by ISRC: %s\n", isrc) + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) @@ -221,6 +223,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur return nil, err } + fmt.Printf("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) + // Find ISRC matches var isrcMatches []*QobuzTrack for i := range result.Tracks.Items { @@ -229,6 +233,8 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur } } + fmt.Printf("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches)) + if len(isrcMatches) > 0 { // Verify duration if provided if expectedDurationSec > 0 { @@ -238,25 +244,25 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur if durationDiff < 0 { durationDiff = -durationDiff } - // Allow 30 seconds tolerance - if durationDiff <= 30 { + // Allow 10 seconds tolerance + if durationDiff <= 10 { durationVerifiedMatches = append(durationVerifiedMatches, track) } } - + if len(durationVerifiedMatches) > 0 { - fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", + fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration) return durationVerifiedMatches[0], nil } - + // ISRC matches but duration doesn't - fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", + fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", isrc, expectedDurationSec, isrcMatches[0].Duration) - return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", + return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", expectedDurationSec, isrcMatches[0].Duration) } - + // No duration to verify, return first match fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) return isrcMatches[0], nil @@ -392,7 +398,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam if durationDiff < 0 { durationDiff = -durationDiff } - if durationDiff <= 30 { + if durationDiff <= 10 { durationMatches = append(durationMatches, track) } } @@ -592,6 +598,7 @@ type QobuzDownloadResult struct { ReleaseDate string TrackNumber int DiscNumber int + ISRC string } // downloadFromQobuz downloads a track using the request parameters @@ -624,10 +631,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Strategy 1: Search by ISRC with duration verification if track == nil && req.ISRC != "" { + fmt.Printf("[Qobuz] Trying ISRC search: %s\n", req.ISRC) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) // Verify artist if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, track.Performer.Name) track = nil } @@ -638,7 +646,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) // Verify artist if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", + fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, track.Performer.Name) track = nil } @@ -731,11 +739,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) + // Use metadata from the actual Qobuz track found (more accurate than request) but prefer + // requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original) + albumName := track.Album.Title + if req.AlbumName != "" { + albumName = req.AlbumName + } + metadata := Metadata{ Title: track.Title, Artist: track.Performer.Name, - Album: track.Album.Title, + Album: albumName, AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct Date: track.Album.ReleaseDate, TrackNumber: track.TrackNumber, @@ -780,5 +794,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { ReleaseDate: track.Album.ReleaseDate, TrackNumber: track.TrackNumber, DiscNumber: req.DiscNumber, // Qobuz track struct limitations + ISRC: track.ISRC, }, nil } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 2865cad..90fbbb5 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -133,11 +133,11 @@ func (t *TidalDownloader) GetAvailableAPIs() []string { "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 + "dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site + "bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site + "aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site + "a2F0emUucXFkbC5zaXRl", // katze.qqdl.site + "d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site } var apis []string @@ -297,7 +297,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { return &trackInfo, nil } - // SearchTrackByISRC searches for a track by ISRC func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { token, err := t.GetAccessToken() @@ -349,7 +348,7 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { // normalizeTitle normalizes a track title for comparison (kept for potential future use) func normalizeTitle(title string) string { normalized := strings.ToLower(strings.TrimSpace(title)) - + // Remove common suffixes in parentheses or brackets suffixPatterns := []string{ " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)", @@ -359,12 +358,12 @@ func normalizeTitle(title string) string { for _, suffix := range suffixPatterns { normalized = strings.TrimSuffix(normalized, suffix) } - + // Remove multiple spaces for strings.Contains(normalized, " ") { normalized = strings.ReplaceAll(normalized, " ", " ") } - + return normalized } @@ -478,6 +477,33 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s if len(result.Items) > 0 { fmt.Printf("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery) + + // OPTIMIZATION: If ISRC provided, check for match immediately and return early + if spotifyISRC != "" { + for i := range result.Items { + if result.Items[i].ISRC == spotifyISRC { + track := &result.Items[i] + // Verify duration if provided + if expectedDuration > 0 { + durationDiff := track.Duration - expectedDuration + if durationDiff < 0 { + durationDiff = -durationDiff + } + if durationDiff <= 3 { + fmt.Printf("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title) + return track, nil + } + // Duration mismatch, continue searching + fmt.Printf("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n", + expectedDuration, track.Duration) + } else { + fmt.Printf("[Tidal] ✓ ISRC match: '%s'\n", track.Title) + return track, nil + } + } + } + } + allTracks = append(allTracks, result.Items...) } } @@ -496,7 +522,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s isrcMatches = append(isrcMatches, track) } } - + if len(isrcMatches) > 0 { // Verify duration first (most important check) if expectedDuration > 0 { @@ -511,26 +537,26 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s 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 } - + // ISRC matches but duration doesn't - this is likely wrong version - fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", + fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", spotifyISRC, expectedDuration, isrcMatches[0].Duration) - return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)", + return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)", expectedDuration, isrcMatches[0].Duration) } - + // No duration to verify, just return first ISRC match 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) @@ -605,7 +631,6 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (* return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0) } - // TidalDownloadInfo contains download URL and quality info type TidalDownloadInfo struct { URL string @@ -648,7 +673,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error())) continue } - + // Log response preview bodyPreview := string(body) if len(bodyPreview) > 300 { @@ -659,16 +684,16 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str // 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", + 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, @@ -725,7 +750,7 @@ 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 { @@ -817,7 +842,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU return "", initURL, mediaURLs, nil } - // DownloadFile downloads a file from URL with progress tracking func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { // Handle manifest-based download (DASH/BTS) @@ -909,7 +933,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s 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", + fmt.Printf("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n", directURL != "", initURL != "", len(mediaURLs)) client := &http.Client{ @@ -984,10 +1008,10 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s // 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 { fmt.Printf("[Tidal] Failed to create M4A file: %v\n", err) @@ -1025,13 +1049,13 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s 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() @@ -1077,43 +1101,44 @@ type TidalDownloadResult struct { ReleaseDate string TrackNumber int DiscNumber int + ISRC string } // artistsMatch checks if the artist names are similar enough func artistsMatch(spotifyArtist, tidalArtist string) bool { normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist)) normTidal := strings.ToLower(strings.TrimSpace(tidalArtist)) - + // Exact match if normSpotify == normTidal { return true } - + // Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone") if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) { return true } - + // Check first artist (before comma or feat) spotifyFirst := strings.Split(normSpotify, ",")[0] spotifyFirst = strings.Split(spotifyFirst, " feat")[0] spotifyFirst = strings.Split(spotifyFirst, " ft.")[0] spotifyFirst = strings.TrimSpace(spotifyFirst) - + tidalFirst := strings.Split(normTidal, ",")[0] tidalFirst = strings.Split(tidalFirst, " feat")[0] tidalFirst = strings.Split(tidalFirst, " ft.")[0] tidalFirst = strings.TrimSpace(tidalFirst) - + if spotifyFirst == tidalFirst { return true } - + // Check if first artist is contained in the other if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) { return true } - + // If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean), // assume they're the same artist with different transliteration // This handles cases like "鈴木雅之" vs "Masayuki Suzuki" @@ -1123,7 +1148,7 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist) return true } - + return false } @@ -1164,13 +1189,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // OPTIMIZED: Try ISRC search first (faster than SongLink API) - // Strategy 1: Search by ISRC with duration verification (FASTEST) + // OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC) + // Strategy 1: Search by metadata, match by ISRC (most accurate) if track == nil && req.ISRC != "" { - fmt.Printf("[Tidal] Trying ISRC search first (faster): %s\n", req.ISRC) + fmt.Printf("[Tidal] Trying ISRC search: %s\n", req.ISRC) track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) - // Verify artist for ISRC match if track != nil { + // Verify artist tidalArtist := track.Artist.Name if len(track.Artists) > 0 { var artistNames []string @@ -1180,7 +1205,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { tidalArtist = strings.Join(artistNames, ", ") } if !artistsMatch(req.ArtistName, tidalArtist) { - fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, tidalArtist) track = nil } @@ -1190,7 +1215,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate) if track == nil && req.SpotifyID != "" { fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n") - tidalURL, slErr := downloader.GetTidalURLFromSpotify(req.SpotifyID) + var tidalURL string + var slErr error + + // Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx") + if strings.HasPrefix(req.SpotifyID, "deezer:") { + deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") + fmt.Printf("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID) + songlink := NewSongLinkClient() + tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID) + } else { + tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID) + } + if slErr == nil && tidalURL != "" { // Extract track ID and get track info trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) @@ -1206,14 +1243,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } tidalArtist = strings.Join(artistNames, ", ") } - + // Verify artist matches if !artistsMatch(req.ArtistName, tidalArtist) { - fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", + fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, tidalArtist) track = nil } - + // Verify duration if we have expected duration if track != nil && expectedDurationSec > 0 { durationDiff := track.Duration - expectedDurationSec @@ -1222,7 +1259,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } // Allow 3 seconds tolerance (same as PC version) if durationDiff > 3 { - fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", + fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", expectedDurationSec, track.Duration) track = nil // Reject this match } @@ -1247,7 +1284,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { tidalArtist = strings.Join(artistNames, ", ") } if !artistsMatch(req.ArtistName, tidalArtist) { - fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", + fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, tidalArtist) track = nil } @@ -1298,7 +1335,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { 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 { @@ -1345,7 +1382,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } 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) @@ -1381,10 +1418,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { Album: req.AlbumName, AlbumArtist: req.AlbumArtist, Date: req.ReleaseDate, - TrackNumber: req.TrackNumber, + TrackNumber: track.TrackNumber, // Use actual track number from Tidal TotalTracks: req.TotalTracks, - DiscNumber: req.DiscNumber, - ISRC: req.ISRC, + DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal + ISRC: track.ISRC, // Use actual ISRC from Tidal } // Use cover data from parallel fetch @@ -1414,18 +1451,18 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } 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 { @@ -1446,5 +1483,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { ReleaseDate: track.Album.ReleaseDate, TrackNumber: track.TrackNumber, DiscNumber: track.VolumeNumber, + ISRC: track.ISRC, }, nil } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index da954d3..ccd1b8e 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -181,6 +181,13 @@ import Gobackend // Import Go framework GobackendCleanupConnections() return nil + case "readFileMetadata": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let response = GobackendReadFileMetadata(filePath, &error) + if let error = error { throw error } + return response + case "searchDeezerAll": let args = call.arguments as! [String: Any] let query = args["query"] as! String diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 107a36b..faeee5f 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.7'; - static const String buildNumber = '45'; + static const String version = '2.2.0'; + static const String buildNumber = '46'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index ca733f1..cad0de2 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -25,7 +25,7 @@ class AppSettings { final String metadataSource; // spotify, deezer - source for search and metadata const AppSettings({ - this.defaultService = 'qobuz', + this.defaultService = 'tidal', this.audioQuality = 'LOSSLESS', this.filenameFormat = '{title} - {artist}', this.downloadDirectory = '', diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 1e6be94..4cfcfe4 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -35,6 +36,9 @@ class DownloadHistoryItem { final int? duration; final String? releaseDate; final String? quality; + // Audio quality info (from file after download) + final int? bitDepth; + final int? sampleRate; const DownloadHistoryItem({ required this.id, @@ -53,6 +57,8 @@ class DownloadHistoryItem { this.duration, this.releaseDate, this.quality, + this.bitDepth, + this.sampleRate, }); Map toJson() => { @@ -72,6 +78,8 @@ class DownloadHistoryItem { 'duration': duration, 'releaseDate': releaseDate, 'quality': quality, + 'bitDepth': bitDepth, + 'sampleRate': sampleRate, }; factory DownloadHistoryItem.fromJson(Map json) => DownloadHistoryItem( @@ -91,6 +99,8 @@ class DownloadHistoryItem { duration: json['duration'] as int?, releaseDate: json['releaseDate'] as String?, quality: json['quality'] as String?, + bitDepth: json['bitDepth'] as int?, + sampleRate: json['sampleRate'] as int?, ); } @@ -763,7 +773,7 @@ class DownloadQueueNotifier extends Notifier { if (track.coverUrl != null && track.coverUrl!.isNotEmpty) { try { final tempDir = await getTemporaryDirectory(); - final uniqueId = DateTime.now().millisecondsSinceEpoch; + final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; coverPath = '${tempDir.path}/cover_$uniqueId.jpg'; // Download cover using HTTP @@ -821,6 +831,8 @@ class DownloadQueueNotifier extends Notifier { if (track.isrc != null) { metadata['ISRC'] = track.isrc!; } + + _log.d('Metadata map content: $metadata'); // Fetch Lyrics (Critical for M4A->FLAC conversion parity) // Since we are in the Flutter context, we can call the bridge to get lyrics @@ -1094,40 +1106,55 @@ class DownloadQueueNotifier extends Notifier { // 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) { + // Enrich metadata if ISRC or track number is missing (common from Search results) + // ISRC is critical for accurate track matching on streaming services + final needsEnrichment = trackToDownload.id.startsWith('deezer:') && + (trackToDownload.isrc == null || trackToDownload.isrc!.isEmpty || + trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0); + + if (needsEnrichment) { 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) + _log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}'); + _log.d('Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}'); + final rawId = trackToDownload.id.split(':')[1]; + _log.d('Fetching full metadata for Deezer ID: $rawId'); + final fullData = await PlatformBridge.getDeezerMetadata('track', rawId); + _log.d('Got response keys: ${fullData.keys.toList()}'); + + if (fullData.containsKey('track')) { + // Parse Go backend response (snake_case) to Track + final trackData = fullData['track']; + _log.d('Track data type: ${trackData.runtimeType}'); + if (trackData is Map) { + final data = trackData; + _log.d('Track data keys: ${data.keys.toList()}'); + _log.d('ISRC from API: ${data['isrc']}'); 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, + id: (data['spotify_id'] as String?) ?? trackToDownload.id, + name: (data['name'] as String?) ?? trackToDownload.name, + artistName: (data['artists'] as String?) ?? trackToDownload.artistName, + albumName: (data['album_name'] as String?) ?? trackToDownload.albumName, + albumArtist: data['album_artist'] as String?, + coverUrl: data['images'] as String?, + // duration_ms from Go is in milliseconds, Track.duration is in seconds + duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ 1000, + isrc: (data['isrc'] as String?) ?? trackToDownload.isrc, + trackNumber: data['track_number'] as int?, + discNumber: data['disc_number'] as int?, + releaseDate: data['release_date'] as String?, + deezerId: rawId, 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 - } + _log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}'); + } else { + _log.w('Unexpected track data type: ${trackData.runtimeType}'); + } + } else { + _log.w('Response does not contain track key'); } - } catch (e) { + } catch (e, stack) { _log.w('Failed to enrich metadata: $e'); + _log.w('Stack trace: $stack'); } } @@ -1257,7 +1284,14 @@ class DownloadQueueNotifier extends Notifier { final backendYear = result['release_date'] as String?; final backendAlbum = result['album'] as String?; - // Create updated track object + _log.d('Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear'); + + // Create updated track object with safety check for 0/null + final newTrackNumber = (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber; + final newDiscNumber = (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber; + + _log.d('Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber'); + finalTrack = Track( id: trackToDownload.id, name: trackToDownload.name, @@ -1267,8 +1301,8 @@ class DownloadQueueNotifier extends Notifier { 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, + trackNumber: newTrackNumber, + discNumber: newDiscNumber, releaseDate: backendYear ?? trackToDownload.releaseDate, deezerId: trackToDownload.deezerId, availability: trackToDownload.availability, @@ -1337,6 +1371,9 @@ class DownloadQueueNotifier extends Notifier { final backendYear = result['release_date'] as String?; final backendTrackNum = result['track_number'] as int?; final backendDiscNum = result['disc_number'] as int?; + final backendBitDepth = result['actual_bit_depth'] as int?; + final backendSampleRate = result['actual_sample_rate'] as int?; + final backendISRC = result['isrc'] as String?; ref.read(downloadHistoryProvider.notifier).addToHistory( DownloadHistoryItem( @@ -1350,13 +1387,15 @@ class DownloadQueueNotifier extends Notifier { service: result['service'] as String? ?? item.service, downloadedAt: DateTime.now(), // Additional metadata - isrc: item.track.isrc, + isrc: (backendISRC != null && backendISRC.isNotEmpty) ? backendISRC : item.track.isrc, spotifyId: item.track.id, trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber, discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber, duration: item.track.duration, releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate, quality: actualQuality, + bitDepth: backendBitDepth, + sampleRate: backendSampleRate, ), ); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index ef05116..90480f0 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -37,11 +37,13 @@ class _TrackMetadataScreenState extends ConsumerState { final file = File(widget.item.filePath); final exists = await file.exists(); int? size; + if (exists) { try { size = await file.length(); } catch (_) {} } + if (mounted) { setState(() { _fileExists = exists; @@ -55,7 +57,18 @@ class _TrackMetadataScreenState extends ConsumerState { } } + // Use data directly from history item (cached from download) DownloadHistoryItem get item => widget.item; + String get trackName => item.trackName; + String get artistName => item.artistName; + String get albumName => item.albumName; + String? get albumArtist => item.albumArtist; + int? get trackNumber => item.trackNumber; + int? get discNumber => item.discNumber; + String? get releaseDate => item.releaseDate; + String? get isrc => item.isrc; + int? get bitDepth => item.bitDepth; + int? get sampleRate => item.sampleRate; @override Widget build(BuildContext context) { @@ -233,9 +246,9 @@ class _TrackMetadataScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Track name + // Track name (from file metadata) Text( - item.trackName, + trackName, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, color: colorScheme.onSurface, @@ -243,16 +256,16 @@ class _TrackMetadataScreenState extends ConsumerState { ), const SizedBox(height: 4), - // Artist name + // Artist name (from file metadata) Text( - item.artistName, + artistName, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: colorScheme.primary, ), ), const SizedBox(height: 8), - // Album name + // Album name (from file metadata) Row( children: [ Icon( @@ -263,7 +276,7 @@ class _TrackMetadataScreenState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - item.albumName, + albumName, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -401,28 +414,33 @@ class _TrackMetadataScreenState extends ConsumerState { } Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) { + // Build audio quality string from file metadata + String? audioQualityStr; + if (bitDepth != null && sampleRate != null) { + final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1); + audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz'; + } + final items = <_MetadataItem>[ - _MetadataItem('Track name', item.trackName), - _MetadataItem('Artist', item.artistName), - if (item.albumArtist != null && item.albumArtist != item.artistName) - _MetadataItem('Album artist', item.albumArtist!), - _MetadataItem('Album', item.albumName), - if (item.trackNumber != null) - _MetadataItem('Track number', item.trackNumber.toString()), - if (item.discNumber != null && item.discNumber! > 1) - _MetadataItem('Disc number', item.discNumber.toString()), + _MetadataItem('Track name', trackName), + _MetadataItem('Artist', artistName), + if (albumArtist != null && albumArtist != artistName) + _MetadataItem('Album artist', albumArtist!), + _MetadataItem('Album', albumName), + if (trackNumber != null && trackNumber! > 0) + _MetadataItem('Track number', trackNumber.toString()), + if (discNumber != null && discNumber! > 1) + _MetadataItem('Disc number', discNumber.toString()), if (item.duration != null) _MetadataItem('Duration', _formatDuration(item.duration!)), - if (item.quality != null && item.quality!.contains('bit')) - _MetadataItem('Audio quality', item.quality!), - if (item.releaseDate != null && item.releaseDate!.isNotEmpty) - _MetadataItem('Release date', item.releaseDate!), - if (item.isrc != null && item.isrc!.isNotEmpty) - _MetadataItem('ISRC', item.isrc!), + if (audioQualityStr != null) + _MetadataItem('Audio quality', audioQualityStr), + if (releaseDate != null && releaseDate!.isNotEmpty) + _MetadataItem('Release date', releaseDate!), + if (isrc != null && isrc!.isNotEmpty) + _MetadataItem('ISRC', isrc!), if (item.spotifyId != null && item.spotifyId!.isNotEmpty) _MetadataItem('Spotify ID', item.spotifyId!), - if (item.quality != null && item.quality!.isNotEmpty) - _MetadataItem('Quality', _formatQuality(item.quality!)), _MetadataItem('Service', item.service.toUpperCase()), _MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)), ]; @@ -476,32 +494,6 @@ class _TrackMetadataScreenState extends ConsumerState { return '$minutes:${secs.toString().padLeft(2, '0')}'; } - String _formatQuality(String quality) { - switch (quality) { - case 'LOSSLESS': - return 'Lossless (16-bit)'; - case 'HI_RES': - return 'Hi-Res (24-bit)'; - case 'HI_RES_LOSSLESS': - return 'Hi-Res Lossless (24-bit)'; - default: - return quality; - } - } - - String _formatQualityShort(String quality) { - switch (quality) { - case 'LOSSLESS': - return '16-bit'; - case 'HI_RES': - return '24-bit'; - case 'HI_RES_LOSSLESS': - return 'Hi-Res'; - default: - return quality; - } - } - Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) { final fileName = item.filePath.split(Platform.pathSeparator).last; final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; @@ -570,7 +562,7 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - if (item.quality != null) + if (bitDepth != null && sampleRate != null) Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( @@ -578,7 +570,7 @@ class _TrackMetadataScreenState extends ConsumerState { borderRadius: BorderRadius.circular(20), ), child: Text( - _formatQualityShort(item.quality!), + '$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz', style: TextStyle( color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, @@ -891,7 +883,7 @@ class _TrackMetadataScreenState extends ConsumerState { ), ListTile( leading: Icon(Icons.delete, color: colorScheme.error), - title: Text('Remove from history', style: TextStyle(color: colorScheme.error)), + title: Text('Remove from device', style: TextStyle(color: colorScheme.error)), onTap: () { Navigator.pop(context); _confirmDelete(context, ref, colorScheme); @@ -908,10 +900,9 @@ class _TrackMetadataScreenState extends ConsumerState { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Remove from history?'), + title: const Text('Remove from device?'), content: const Text( - 'This will remove the track from your download history. ' - 'The downloaded file will not be deleted.', + 'This will permanently delete the downloaded file and remove it from your history.', ), actions: [ TextButton( @@ -919,12 +910,26 @@ class _TrackMetadataScreenState extends ConsumerState { child: const Text('Cancel'), ), TextButton( - onPressed: () { + onPressed: () async { + // Delete the file first + try { + final file = File(item.filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + debugPrint('Failed to delete file: $e'); + } + + // Remove from history ref.read(downloadHistoryProvider.notifier).removeFromHistory(item.id); - Navigator.pop(context); // Close dialog - Navigator.pop(context); // Go back to history + + if (context.mounted) { + Navigator.pop(context); // Close dialog + Navigator.pop(context); // Go back to history + } }, - child: Text('Remove', style: TextStyle(color: colorScheme.error)), + child: Text('Delete', style: TextStyle(color: colorScheme.error)), ), ], ), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 9ee8f9a..09458cc 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -111,7 +111,7 @@ class PlatformBridge { int discNumber = 1, int totalTracks = 1, String? releaseDate, - String preferredService = 'qobuz', + String preferredService = 'tidal', String? itemId, int durationMs = 0, }) async { @@ -248,6 +248,16 @@ class PlatformBridge { await _channel.invokeMethod('cleanupConnections'); } + /// Read metadata directly from a FLAC file + /// Returns all embedded metadata (title, artist, album, track number, etc.) + /// This reads from the actual file, not from cached/database data + static Future> readFileMetadata(String filePath) async { + final result = await _channel.invokeMethod('readFileMetadata', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + /// Start foreground download service to keep downloads running in background static Future startDownloadService({ String trackName = '', @@ -335,6 +345,9 @@ class PlatformBridge { 'resource_type': resourceType, 'resource_id': resourceId, }); + if (result == null) { + throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId'); + } return jsonDecode(result as String) as Map; } diff --git a/pubspec.yaml b/pubspec.yaml index 0819bc1..d9447cb 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.7+45 +version: 2.2.0+46 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index bc92238..8520874 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.7+45 +version: 2.2.0+46 environment: sdk: ^3.10.0