From a1d1ab1f0f90f554ccb24abb9b8ce3bb2019f39c Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 12 Feb 2026 00:19:02 +0700 Subject: [PATCH] fix: preserve extended metadata during fallback, accurate lossy quality display, SAF improvements - Add Genre/Label/Copyright fields to DownloadResult struct - buildDownloadSuccessResponse now prefers service result metadata over request - enrichRequestExtendedMetadata fetches Deezer metadata by ISRC before download - Flutter sends copyright in download request payload - History merge preserves existing genre/label/copyright on re-download - Accurate MP3 duration via Xing/VBRI VBR headers, MPEG2/2.5 bitrate tables - Accurate Opus/Vorbis duration via last Ogg page granule position - Bitrate field added to LibraryScanResult, LocalLibraryItem, DB v4 migration - Lossy formats display format+bitrate instead of fake 16-bit quality - Local library file date uses fileModTime instead of scannedAt - SAF URI recovery for transient FD paths after download - Improved SAF repair and download history path matching in library scan - Extract quality probe logic into reusable enrichResultQualityFromFile --- go_backend/audio_metadata.go | 234 +++++++++++++++++---- go_backend/exports.go | 131 +++++++++--- go_backend/library_scan.go | 11 +- lib/providers/download_queue_provider.dart | 182 +++++++++++----- lib/providers/local_library_provider.dart | 77 ++++++- lib/screens/local_album_screen.dart | 16 +- lib/screens/queue_tab.dart | 12 +- lib/screens/track_metadata_screen.dart | 48 ++++- lib/services/library_database.dart | 15 +- lib/services/platform_bridge.dart | 6 +- 10 files changed, 589 insertions(+), 143 deletions(-) diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index e743d352..86112467 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -43,6 +43,7 @@ type OggQuality struct { SampleRate int BitDepth int Duration int + Bitrate int // estimated bitrate in bps } // ============================================================================= @@ -664,50 +665,144 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) { file.Seek(audioStart, io.SeekStart) + // Find first valid MP3 frame sync frameHeader := make([]byte, 4) - for i := 0; i < 10000; i++ { // Search first 10KB + var frameStart int64 = -1 + for i := 0; i < 10000; i++ { if _, err := io.ReadFull(file, frameHeader); err != nil { break } if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 { - version := (frameHeader[1] >> 3) & 0x03 - layer := (frameHeader[1] >> 1) & 0x03 - bitrateIdx := (frameHeader[2] >> 4) & 0x0F - sampleRateIdx := (frameHeader[2] >> 2) & 0x03 - - sampleRates := [][]int{ - {11025, 12000, 8000}, - {0, 0, 0}, - {22050, 24000, 16000}, - {44100, 48000, 32000}, - } - if version < 4 && sampleRateIdx < 3 { - quality.SampleRate = sampleRates[version][sampleRateIdx] - } - - if version == 3 && layer == 1 { - bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0} - if bitrateIdx < 16 { - quality.Bitrate = bitrates[bitrateIdx] * 1000 - } - } - - quality.BitDepth = 16 - - if quality.Bitrate > 0 { - audioSize := fileSize - audioStart - 128 - if audioSize > 0 { - quality.Duration = int(audioSize * 8 / int64(quality.Bitrate)) - } - } - + pos, _ := file.Seek(0, io.SeekCurrent) + frameStart = pos - 4 break } file.Seek(-3, io.SeekCurrent) } + if frameStart < 0 { + return quality, nil + } + + version := (frameHeader[1] >> 3) & 0x03 + layer := (frameHeader[1] >> 1) & 0x03 + bitrateIdx := (frameHeader[2] >> 4) & 0x0F + sampleRateIdx := (frameHeader[2] >> 2) & 0x03 + channelMode := (frameHeader[3] >> 6) & 0x03 + + // Sample rate tables: [version][index] + // version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1 + sampleRates := [][]int{ + {11025, 12000, 8000}, + {0, 0, 0}, + {22050, 24000, 16000}, + {44100, 48000, 32000}, + } + if version < 4 && sampleRateIdx < 3 { + quality.SampleRate = sampleRates[version][sampleRateIdx] + } + + // Bitrate tables for all MPEG versions and layers + // MPEG1 Layer III + if version == 3 && layer == 1 { + bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0} + if bitrateIdx < 16 { + quality.Bitrate = bitrates[bitrateIdx] * 1000 + } + } + // MPEG2/2.5 Layer III + if (version == 0 || version == 2) && layer == 1 { + bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0} + if bitrateIdx < 16 { + quality.Bitrate = bitrates[bitrateIdx] * 1000 + } + } + + // Determine samples per frame for duration calculation + samplesPerFrame := 1152 // MPEG1 Layer III + if version == 0 || version == 2 { + samplesPerFrame = 576 // MPEG2/2.5 Layer III + } + + // Try to read Xing/VBRI header from the first frame for VBR info + // Xing header offset depends on MPEG version and channel mode + var xingOffset int + if version == 3 { // MPEG1 + if channelMode == 3 { // Mono + xingOffset = 17 + } else { + xingOffset = 32 + } + } else { // MPEG2/2.5 + if channelMode == 3 { + xingOffset = 9 + } else { + xingOffset = 17 + } + } + + // Read enough of the first frame to find Xing/VBRI header + xingBuf := make([]byte, 200) + file.Seek(frameStart+4, io.SeekStart) + n, _ := io.ReadFull(file, xingBuf) + xingBuf = xingBuf[:n] + + vbrFrames := 0 + vbrBytes := int64(0) + isVBR := false + + // Check for Xing/Info header + if xingOffset+8 <= n { + tag := string(xingBuf[xingOffset : xingOffset+4]) + if tag == "Xing" || tag == "Info" { + flags := binary.BigEndian.Uint32(xingBuf[xingOffset+4 : xingOffset+8]) + off := xingOffset + 8 + if flags&0x01 != 0 && off+4 <= n { // Frames flag + vbrFrames = int(binary.BigEndian.Uint32(xingBuf[off : off+4])) + off += 4 + } + if flags&0x02 != 0 && off+4 <= n { // Bytes flag + vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[off : off+4])) + } + if vbrFrames > 0 { + isVBR = true + } + } + } + + // Check for VBRI header (always at offset 32 from frame start + 4) + if !isVBR && 36+26 <= n { + if string(xingBuf[32:36]) == "VBRI" { + vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10])) + vbrFrames = int(binary.BigEndian.Uint32(xingBuf[36+10 : 36+14])) + if vbrFrames > 0 { + isVBR = true + } + } + } + + if isVBR && vbrFrames > 0 && quality.SampleRate > 0 { + // Accurate duration from total frames + totalSamples := int64(vbrFrames) * int64(samplesPerFrame) + quality.Duration = int(totalSamples / int64(quality.SampleRate)) + + // Accurate average bitrate + if vbrBytes > 0 && quality.Duration > 0 { + quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration)) + } else if quality.Duration > 0 { + audioSize := fileSize - audioStart + quality.Bitrate = int(audioSize * 8 / int64(quality.Duration)) + } + } else if quality.Bitrate > 0 { + // CBR fallback: estimate duration from file size and frame bitrate + audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag + if audioSize > 0 { + quality.Duration = int(audioSize * 8 / int64(quality.Bitrate)) + } + } + return quality, nil } @@ -981,7 +1076,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) { defer file.Close() quality := &OggQuality{} - isOpus := false packets, err := collectOggPackets(file, 5, 10) if err != nil && len(packets) == 0 { @@ -997,15 +1091,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) { } } - if streamType == oggStreamOpus { - isOpus = true + isOpus := streamType == oggStreamOpus + var preSkip int + + if isOpus { for _, pkt := range packets { if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" { quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) if quality.SampleRate == 0 { quality.SampleRate = 48000 } - quality.BitDepth = 16 + preSkip = int(binary.LittleEndian.Uint16(pkt[10:12])) break } } @@ -1013,26 +1109,76 @@ func GetOggQuality(filePath string) (*OggQuality, error) { for _, pkt := range packets { if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" { quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16])) - quality.BitDepth = 16 break } } } + // Read granule position from the last Ogg page for accurate duration stat, err := file.Stat() - if err == nil { - // Very rough duration estimate based on file size - // Assume ~128kbps average for Opus, ~160kbps for Vorbis - avgBitrate := 128000 - if !isOpus { - avgBitrate = 160000 + if err != nil { + return quality, nil + } + fileSize := stat.Size() + + granule := readLastOggGranulePosition(file, fileSize) + if granule > 0 { + if isOpus { + // Opus always uses 48kHz granule position internally + totalSamples := granule - int64(preSkip) + if totalSamples > 0 { + quality.Duration = int(totalSamples / 48000) + } + } else if quality.SampleRate > 0 { + quality.Duration = int(granule / int64(quality.SampleRate)) } - quality.Duration = int(stat.Size() * 8 / int64(avgBitrate)) + } + + // Calculate average bitrate from file size and actual duration + if quality.Duration > 0 { + quality.Bitrate = int(fileSize * 8 / int64(quality.Duration)) } return quality, nil } +// readLastOggGranulePosition seeks to the end of the file and scans backwards +// to find the last Ogg page, then reads its granule position (bytes 6-13). +func readLastOggGranulePosition(file *os.File, fileSize int64) int64 { + // Read the last chunk of the file to find the last OggS sync + searchSize := int64(65536) + if searchSize > fileSize { + searchSize = fileSize + } + + buf := make([]byte, searchSize) + offset := fileSize - searchSize + if offset < 0 { + offset = 0 + } + n, err := file.ReadAt(buf, offset) + if err != nil && n == 0 { + return 0 + } + buf = buf[:n] + + // Scan backwards for "OggS" magic + lastPageOffset := -1 + for i := n - 4; i >= 0; i-- { + if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' { + lastPageOffset = i + break + } + } + + if lastPageOffset < 0 || lastPageOffset+14 > n { + return 0 + } + + // Granule position is at bytes 6-13 of the Ogg page header (little-endian int64) + return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14])) +} + // ============================================================================= // ID3v1 Genre List // ============================================================================= diff --git a/go_backend/exports.go b/go_backend/exports.go index 91f68711..7020cfdc 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -213,6 +213,9 @@ type DownloadResult struct { TrackNumber int DiscNumber int ISRC string + Genre string + Label string + Copyright string LyricsLRC string DecryptionKey string } @@ -260,6 +263,21 @@ func buildDownloadSuccessResponse( isrc = req.ISRC } + genre := result.Genre + if genre == "" { + genre = req.Genre + } + + label := result.Label + if label == "" { + label = req.Label + } + + copyright := result.Copyright + if copyright == "" { + copyright = req.Copyright + } + return DownloadResponse{ Success: true, Message: message, @@ -277,14 +295,85 @@ func buildDownloadSuccessResponse( DiscNumber: discNumber, ISRC: isrc, CoverURL: req.CoverURL, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, + Genre: genre, + Label: label, + Copyright: copyright, LyricsLRC: result.LyricsLRC, DecryptionKey: result.DecryptionKey, } } +func shouldSkipQualityProbe(filePath string) bool { + path := strings.TrimSpace(filePath) + if path == "" { + return true + } + if strings.HasPrefix(path, "/proc/self/fd/") { + return true + } + // Content URI and other non-filesystem schemes cannot be read directly by os.Open. + if strings.Contains(path, "://") { + return true + } + return false +} + +func enrichResultQualityFromFile(result *DownloadResult) { + if result == nil { + return + } + + path := strings.TrimSpace(result.FilePath) + if shouldSkipQualityProbe(path) { + if strings.HasPrefix(path, "/proc/self/fd/") { + LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path) + } + return + } + + quality, qErr := GetAudioQuality(path) + if qErr == nil { + result.BitDepth = quality.BitDepth + result.SampleRate = quality.SampleRate + GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + return + } + + LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr) +} + +func enrichRequestExtendedMetadata(req *DownloadRequest) { + if req == nil { + return + } + + if req.ISRC == "" || (req.Genre != "" && req.Label != "") { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + deezerClient := GetDeezerClient() + extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC) + if err != nil || extMeta == nil { + if err != nil { + GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err) + } + return + } + + if req.Genre == "" && extMeta.Genre != "" { + req.Genre = extMeta.Genre + } + if req.Label == "" && extMeta.Label != "" { + req.Label = extMeta.Label + } + if req.Genre != "" || req.Label != "" { + GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label) + } +} + func DownloadTrack(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { @@ -303,6 +392,8 @@ func DownloadTrack(requestJSON string) (string, error) { AddAllowedDownloadDir(req.OutputDir) } + enrichRequestExtendedMetadata(&req) + var result DownloadResult var err error @@ -390,11 +481,8 @@ func DownloadTrack(requestJSON string) (string, error) { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { actualPath := result.FilePath[7:] - quality, qErr := GetAudioQuality(actualPath) - if qErr == nil { - result.BitDepth = quality.BitDepth - result.SampleRate = quality.SampleRate - } + result.FilePath = actualPath + enrichResultQualityFromFile(&result) resp := buildDownloadSuccessResponse( req, result, @@ -407,14 +495,7 @@ func DownloadTrack(requestJSON string) (string, error) { return string(jsonBytes), nil } - quality, qErr := GetAudioQuality(result.FilePath) - if qErr == nil { - result.BitDepth = quality.BitDepth - result.SampleRate = quality.SampleRate - GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) - } else { - GoLog("[Download] Could not read quality from file: %v\n", qErr) - } + enrichResultQualityFromFile(&result) resp := buildDownloadSuccessResponse( req, @@ -488,6 +569,8 @@ func DownloadWithFallback(requestJSON string) (string, error) { AddAllowedDownloadDir(req.OutputDir) } + enrichRequestExtendedMetadata(&req) + allServices := []string{"tidal", "qobuz", "amazon"} preferredService := req.Service if preferredService == "" { @@ -585,11 +668,8 @@ func DownloadWithFallback(requestJSON string) (string, error) { if err == nil { if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { actualPath := result.FilePath[7:] - quality, qErr := GetAudioQuality(actualPath) - if qErr == nil { - result.BitDepth = quality.BitDepth - result.SampleRate = quality.SampleRate - } + result.FilePath = actualPath + enrichResultQualityFromFile(&result) resp := buildDownloadSuccessResponse( req, result, @@ -602,14 +682,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { return string(jsonBytes), nil } - quality, qErr := GetAudioQuality(result.FilePath) - if qErr == nil { - result.BitDepth = quality.BitDepth - result.SampleRate = quality.SampleRate - GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) - } else { - GoLog("[Download] Could not read quality from file: %v\n", qErr) - } + enrichResultQualityFromFile(&result) resp := buildDownloadSuccessResponse( req, diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 7ed3fee7..e0a1eafe 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -28,6 +28,7 @@ type LibraryScanResult struct { ReleaseDate string `json:"releaseDate,omitempty"` BitDepth int `json:"bitDepth,omitempty"` SampleRate int `json:"sampleRate,omitempty"` + Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis) Genre string `json:"genre,omitempty"` Format string `json:"format,omitempty"` } @@ -289,8 +290,11 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult quality, err := GetMP3Quality(filePath) if err == nil { result.SampleRate = quality.SampleRate - result.BitDepth = quality.BitDepth + result.BitDepth = quality.BitDepth // 0 for lossy result.Duration = quality.Duration + if quality.Bitrate > 0 { + result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps + } } if result.TrackName == "" { @@ -326,8 +330,11 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult quality, err := GetOggQuality(filePath) if err == nil { result.SampleRate = quality.SampleRate - result.BitDepth = quality.BitDepth + result.BitDepth = quality.BitDepth // 0 for lossy result.Duration = quality.Duration + if quality.Bitrate > 0 { + result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps + } } if result.TrackName == "" { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index e63c276d..3b2003e5 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -323,7 +323,10 @@ class DownloadHistoryNotifier extends Notifier { if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) { continue; } - if (item.filePath.isEmpty || !isContentUri(item.filePath)) { + final hasFilePath = item.filePath.trim().isNotEmpty; + final hasSafFileName = + item.safFileName != null && item.safFileName!.trim().isNotEmpty; + if (!hasFilePath && !hasSafFileName) { continue; } candidateIndexes.add(i); @@ -344,52 +347,59 @@ class DownloadHistoryNotifier extends Notifier { for (var c = 0; c < candidateIndexes.length; c++) { final i = candidateIndexes[c]; final item = items[i]; + final rawPath = item.filePath.trim(); + final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath); - final exists = await fileExists(item.filePath); - if (exists) { - final verified = item.copyWith( - safRepaired: true, - safFileName: item.safFileName ?? _fileNameFromUri(item.filePath), - ); - updatedItems[i] = verified; - changed = true; - verifiedCount++; - await _db.upsert(verified.toJson()); - } else { - final fallbackName = - item.safFileName ?? _fileNameFromUri(item.filePath); - if (fallbackName.isEmpty) { - _historyLog.w('Missing SAF filename for history item: ${item.id}'); + if (isDirectSafUri) { + final exists = await fileExists(rawPath); + if (exists) { + final verified = item.copyWith( + safRepaired: true, + safFileName: item.safFileName ?? _fileNameFromUri(rawPath), + ); + updatedItems[i] = verified; + changed = true; + verifiedCount++; + await _db.upsert(verified.toJson()); continue; } + } - try { - final resolved = await PlatformBridge.resolveSafFile( - treeUri: item.downloadTreeUri!, - relativeDir: item.safRelativeDir ?? '', - fileName: fallbackName, - ); - final newUri = resolved['uri'] as String? ?? ''; - if (newUri.isEmpty) continue; + var fallbackName = (item.safFileName ?? '').trim(); + if (fallbackName.isEmpty && isDirectSafUri) { + fallbackName = _fileNameFromUri(rawPath); + } + if (fallbackName.isEmpty) { + _historyLog.w('Missing SAF filename for history item: ${item.id}'); + continue; + } - final newRelativeDir = resolved['relative_dir'] as String?; - final updated = item.copyWith( - filePath: newUri, - safRelativeDir: - (newRelativeDir != null && newRelativeDir.isNotEmpty) - ? newRelativeDir - : item.safRelativeDir, - safFileName: fallbackName, - safRepaired: true, - ); + try { + final resolved = await PlatformBridge.resolveSafFile( + treeUri: item.downloadTreeUri!, + relativeDir: item.safRelativeDir ?? '', + fileName: fallbackName, + ); + final newUri = (resolved['uri'] as String? ?? '').trim(); + if (newUri.isEmpty) continue; - updatedItems[i] = updated; - changed = true; - repairedCount++; - await _db.upsert(updated.toJson()); - } catch (e) { - _historyLog.w('Failed to repair SAF URI: $e'); - } + final newRelativeDir = resolved['relative_dir'] as String?; + final updated = item.copyWith( + filePath: newUri, + safRelativeDir: + (newRelativeDir != null && newRelativeDir.isNotEmpty) + ? newRelativeDir + : item.safRelativeDir, + safFileName: fallbackName, + safRepaired: true, + ); + + updatedItems[i] = updated; + changed = true; + repairedCount++; + await _db.upsert(updated.toJson()); + } catch (e) { + _historyLog.w('Failed to repair SAF URI: $e'); } if ((c + 1) % _safRepairBatchSize == 0) { @@ -421,19 +431,33 @@ class DownloadHistoryNotifier extends Notifier { existing = state.getByIsrc(item.isrc!); } + final mergedItem = existing == null + ? item + : item.copyWith( + genre: + _normalizeOptionalString(item.genre) ?? + _normalizeOptionalString(existing.genre), + label: + _normalizeOptionalString(item.label) ?? + _normalizeOptionalString(existing.label), + copyright: + _normalizeOptionalString(item.copyright) ?? + _normalizeOptionalString(existing.copyright), + ); + if (existing != null) { final updatedItems = state.items .where((i) => i.id != existing!.id) .toList(); - updatedItems.insert(0, item); + updatedItems.insert(0, mergedItem); state = state.copyWith(items: updatedItems); - _historyLog.d('Updated existing history entry: ${item.trackName}'); + _historyLog.d('Updated existing history entry: ${mergedItem.trackName}'); } else { - state = state.copyWith(items: [item, ...state.items]); - _historyLog.d('Added new history entry: ${item.trackName}'); + state = state.copyWith(items: [mergedItem, ...state.items]); + _historyLog.d('Added new history entry: ${mergedItem.trackName}'); } - _db.upsert(item.toJson()).catchError((e) { + _db.upsert(mergedItem.toJson()).catchError((e) { _historyLog.e('Failed to save to database: $e'); }); } @@ -2768,6 +2792,7 @@ class DownloadQueueNotifier extends Notifier { String? genre; String? label; + String? copyright; String? deezerTrackId = trackToDownload.deezerId; if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { @@ -2889,8 +2914,11 @@ class DownloadQueueNotifier extends Notifier { if (extendedMetadata != null) { genre = extendedMetadata['genre']; label = extendedMetadata['label']; + copyright = extendedMetadata['copyright']; if (genre != null && genre.isNotEmpty) { - _log.d('Extended metadata - Genre: $genre, Label: $label'); + _log.d( + 'Extended metadata - Genre: $genre, Label: $label, Copyright: $copyright', + ); } } } catch (e) { @@ -2960,6 +2988,7 @@ class DownloadQueueNotifier extends Notifier { source: trackToDownload.source ?? '', genre: genre ?? '', label: label ?? '', + copyright: copyright ?? '', deezerId: deezerTrackId ?? '', lyricsMode: settings.lyricsMode, storageMode: storageMode, @@ -3748,6 +3777,47 @@ class DownloadQueueNotifier extends Notifier { return; } + // SAF downloads should end with content URI. If we still have a + // transient FD path, recover URI from SAF metadata to keep history + // dedup/exclusion stable. + if (effectiveSafMode && + filePath != null && + filePath.isNotEmpty && + !isContentUri(filePath) && + settings.downloadTreeUri.isNotEmpty) { + final fallbackName = (finalSafFileName ?? safFileName ?? '').trim(); + if (fallbackName.isNotEmpty) { + try { + final resolved = await PlatformBridge.resolveSafFile( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: fallbackName, + ); + final resolvedUri = (resolved['uri'] as String? ?? '').trim(); + final resolvedRelativeDir = + (resolved['relative_dir'] as String? ?? '').trim(); + if (resolvedUri.isNotEmpty && isContentUri(resolvedUri)) { + _log.w('Recovered SAF URI from transient path: $filePath'); + filePath = resolvedUri; + finalSafFileName = fallbackName; + if (resolvedRelativeDir.isNotEmpty) { + effectiveOutputDir = resolvedRelativeDir; + } + } else { + _log.w( + 'Failed to recover SAF URI (fileName=$fallbackName, dir=$effectiveOutputDir)', + ); + } + } catch (e) { + _log.w('SAF URI recovery failed: $e'); + } + } else { + _log.w( + 'SAF download returned non-URI path without filename metadata: $filePath', + ); + } + } + updateItemStatus( item.id, DownloadStatus.completed, @@ -3840,6 +3910,18 @@ class DownloadQueueNotifier extends Notifier { final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; + final effectiveGenre = + _normalizeOptionalString(backendGenre) ?? + _normalizeOptionalString(genre) ?? + _normalizeOptionalString(existingInHistory?.genre); + final effectiveLabel = + _normalizeOptionalString(backendLabel) ?? + _normalizeOptionalString(label) ?? + _normalizeOptionalString(existingInHistory?.label); + final effectiveCopyright = + _normalizeOptionalString(backendCopyright) ?? + _normalizeOptionalString(copyright) ?? + _normalizeOptionalString(existingInHistory?.copyright); _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); @@ -3899,9 +3981,9 @@ class DownloadQueueNotifier extends Notifier { quality: actualQuality, bitDepth: historyBitDepth, sampleRate: historySampleRate, - genre: backendGenre, - label: backendLabel, - copyright: backendCopyright, + genre: effectiveGenre, + label: effectiveLabel, + copyright: effectiveCopyright, ), ); diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index fcaa0f1c..58028ddd 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -180,6 +181,58 @@ class LocalLibraryNotifier extends Notifier { await _loadFromDatabase(); } + Set _buildPathMatchKeys(String? filePath) { + final raw = filePath?.trim() ?? ''; + if (raw.isEmpty) return const {}; + + final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw; + final keys = {cleaned}; + + void addNormalized(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return; + keys.add(trimmed); + keys.add(trimmed.toLowerCase()); + if (trimmed.contains('\\')) { + final slash = trimmed.replaceAll('\\', '/'); + keys.add(slash); + keys.add(slash.toLowerCase()); + } + if (trimmed.contains('%')) { + try { + final decoded = Uri.decodeFull(trimmed); + keys.add(decoded); + keys.add(decoded.toLowerCase()); + } catch (_) {} + } + } + + addNormalized(cleaned); + + if (cleaned.startsWith('content://')) { + try { + final uri = Uri.parse(cleaned); + addNormalized(uri.toString()); + addNormalized(uri.replace(query: null, fragment: null).toString()); + } catch (_) {} + } + + return keys; + } + + bool _isDownloadedPath(String? filePath, Set downloadedPathKeys) { + if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) { + return false; + } + final candidateKeys = _buildPathMatchKeys(filePath); + for (final key in candidateKeys) { + if (downloadedPathKeys.contains(key)) { + return true; + } + } + return false; + } + Future startScan( String folderPath, { bool forceFullScan = false, @@ -217,10 +270,26 @@ class LocalLibraryNotifier extends Notifier { try { final isSaf = folderPath.startsWith('content://'); - // Get all file paths from download history to exclude them + // Get all file paths from download history to exclude them. + // Merge DB + in-memory state to avoid race when a fresh download has not + // been flushed to SQLite yet. final downloadedPaths = await _historyDb.getAllFilePaths(); + final inMemoryHistoryPaths = ref + .read(downloadHistoryProvider) + .items + .map((item) => item.filePath) + .where((path) => path.isNotEmpty); + final allHistoryPaths = { + ...downloadedPaths, + ...inMemoryHistoryPaths, + }; + final downloadedPathKeys = {}; + for (final path in allHistoryPaths) { + downloadedPathKeys.addAll(_buildPathMatchKeys(path)); + } _log.i( - 'Excluding ${downloadedPaths.length} downloaded files from library scan', + 'Excluding ${allHistoryPaths.length} downloaded files from library scan ' + '(${downloadedPathKeys.length} path keys)', ); if (forceFullScan) { @@ -238,7 +307,7 @@ class LocalLibraryNotifier extends Notifier { for (final json in results) { final filePath = json['filePath'] as String?; // Skip files that are already in download history - if (filePath != null && downloadedPaths.contains(filePath)) { + if (_isDownloadedPath(filePath, downloadedPathKeys)) { skippedDownloads++; continue; } @@ -344,7 +413,7 @@ class LocalLibraryNotifier extends Notifier { for (final json in scannedList) { final map = json as Map; final filePath = map['filePath'] as String?; - if (filePath != null && downloadedPaths.contains(filePath)) { + if (_isDownloadedPath(filePath, downloadedPathKeys)) { skippedDownloads++; continue; } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 529ad159..a86bc2aa 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -560,7 +560,21 @@ class _LocalAlbumScreenState extends ConsumerState { String? _computeCommonQuality(List tracks) { if (tracks.isEmpty) return null; final first = tracks.first; - if (first.bitDepth == null || first.sampleRate == null) return null; + + // For lossy formats, use bitrate + if (first.bitrate != null && first.bitrate! > 0) { + final fmt = first.format?.toUpperCase() ?? ''; + final firstBitrate = first.bitrate; + for (final track in tracks) { + if (track.bitrate != firstBitrate) { + return null; + } + } + return '$fmt ${firstBitrate}kbps'.trim(); + } + + // For lossless formats, use bit depth / sample rate + if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null; final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz'; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 6b03573f..4327516c 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -70,7 +70,12 @@ class UnifiedLibraryItem { factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) { String? quality; - if (item.bitDepth != null && item.sampleRate != null) { + if (item.bitrate != null && item.bitrate! > 0) { + // Lossy format with bitrate + final fmt = item.format?.toUpperCase() ?? ''; + quality = '$fmt ${item.bitrate}kbps'.trim(); + } else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) { + // Lossless format with actual bit depth quality = '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; } @@ -897,7 +902,10 @@ class _QueueTabState extends ConsumerState { } String? _localQualityLabel(LocalLibraryItem item) { - if (item.bitDepth == null || item.sampleRate == null) { + if (item.bitrate != null && item.bitrate! > 0) { + return '${item.bitrate}kbps'; + } + if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { return null; } return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 1d87551e..51c8bfef 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -416,6 +416,7 @@ class _TrackMetadataScreenState extends ConsumerState { _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth; int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate; + int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null; String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; @@ -424,8 +425,17 @@ class _TrackMetadataScreenState extends ConsumerState { _isLocalItem ? _localLibraryItem!.coverPath : null; String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; String get _service => _isLocalItem ? 'local' : _downloadItem!.service; - DateTime get _addedAt => - _isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt; + DateTime get _addedAt { + if (_isLocalItem) { + // Use file modification time if available, otherwise fall back to scannedAt + final modTime = _localLibraryItem!.fileModTime; + if (modTime != null && modTime > 0) { + return DateTime.fromMillisecondsSinceEpoch(modTime); + } + return _localLibraryItem!.scannedAt; + } + return _downloadItem!.downloadedAt; + } String? get _quality => _isLocalItem ? null : _downloadItem!.quality; String get cleanFilePath { @@ -921,8 +931,12 @@ class _TrackMetadataScreenState extends ConsumerState { // Use stored quality from download history if available if (_quality != null && _quality!.isNotEmpty) { audioQualityStr = _quality; - } else if (bitDepth != null && sampleRate != null) { - // Fallback for FLAC files without stored quality + } else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) { + // Lossy local file with bitrate info + final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt; + audioQualityStr = '$fmt ${_localBitrate}kbps'; + } else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) { + // Lossless file with actual bit depth (FLAC, ALAC) final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1); audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz'; } else { @@ -1128,7 +1142,31 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ) - else if (bitDepth != null && sampleRate != null) + else if (_isLocalItem && + _localBitrate != null && + _localBitrate! > 0 && + (fileExtension == 'MP3' || + fileExtension == 'OPUS' || + fileExtension == 'OGG')) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_localBitrate}kbps', + style: TextStyle( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ) + else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) Container( padding: const EdgeInsets.symmetric( horizontal: 12, diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index c15b0c99..b2243e2b 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -23,6 +23,7 @@ class LocalLibraryItem { final String? releaseDate; final int? bitDepth; final int? sampleRate; + final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg) final String? genre; final String? format; // flac, mp3, opus, m4a @@ -43,6 +44,7 @@ class LocalLibraryItem { this.releaseDate, this.bitDepth, this.sampleRate, + this.bitrate, this.genre, this.format, }); @@ -64,6 +66,7 @@ class LocalLibraryItem { 'releaseDate': releaseDate, 'bitDepth': bitDepth, 'sampleRate': sampleRate, + 'bitrate': bitrate, 'genre': genre, 'format': format, }; @@ -86,6 +89,7 @@ class LocalLibraryItem { releaseDate: json['releaseDate'] as String?, bitDepth: json['bitDepth'] as int?, sampleRate: json['sampleRate'] as int?, + bitrate: (json['bitrate'] as num?)?.toInt(), genre: json['genre'] as String?, format: json['format'] as String?, ); @@ -115,7 +119,7 @@ class LibraryDatabase { return await openDatabase( path, - version: 3, // Bumped version for file_mod_time migration + version: 4, // Bumped version for bitrate column onCreate: _createDB, onUpgrade: _upgradeDB, ); @@ -142,6 +146,7 @@ class LibraryDatabase { release_date TEXT, bit_depth INTEGER, sample_rate INTEGER, + bitrate INTEGER, genre TEXT, format TEXT ) @@ -169,6 +174,12 @@ class LibraryDatabase { await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER'); _log.i('Added file_mod_time column for incremental scanning'); } + + if (oldVersion < 4) { + // Add bitrate column for lossy format quality info + await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER'); + _log.i('Added bitrate column for lossy format quality'); + } } Map _jsonToDbRow(Map json) { @@ -189,6 +200,7 @@ class LibraryDatabase { 'release_date': json['releaseDate'], 'bit_depth': json['bitDepth'], 'sample_rate': json['sampleRate'], + 'bitrate': json['bitrate'], 'genre': json['genre'], 'format': json['format'], }; @@ -212,6 +224,7 @@ class LibraryDatabase { 'releaseDate': row['release_date'], 'bitDepth': row['bit_depth'], 'sampleRate': row['sample_rate'], + 'bitrate': row['bitrate'], 'genre': row['genre'], 'format': row['format'], }; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 9e84c4bf..b879bf5d 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -103,8 +103,6 @@ class PlatformBridge { return response; } - - static Future> getDownloadProgress() async { final result = await _channel.invokeMethod('getDownloadProgress'); return jsonDecode(result as String) as Map; @@ -509,6 +507,7 @@ class PlatformBridge { return { 'genre': data['genre'] as String? ?? '', 'label': data['label'] as String? ?? '', + 'copyright': data['copyright'] as String? ?? '', }; } catch (e) { _log.w('Failed to get Deezer extended metadata for $trackId: $e'); @@ -719,8 +718,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - - static Future cleanupExtensions() async { _log.d('cleanupExtensions'); await _channel.invokeMethod('cleanupExtensions'); @@ -1130,5 +1127,4 @@ class PlatformBridge { } // ==================== YOUTUBE / COBALT ==================== - }