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 93656a25..7cac46a1 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -777,20 +777,32 @@ class MainActivity: FlutterFragmentActivity() { return if (ext.isNullOrBlank()) "audio" else "audio$ext" } + private fun buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String { + val normalizedPath = stablePath.trim() + if (normalizedPath.isEmpty()) return "" + return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath + } + private fun readAudioMetadataFromUri( uri: Uri, displayNameHint: String? = null, fallbackExt: String? = null, + coverCacheKey: String = "", ): JSONObject? { val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt) try { contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> val directPath = "/proc/self/fd/${pfd.fd}" - val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName) + val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON( + directPath, + displayName, + coverCacheKey, + ) if (metadataJson.isNotBlank()) { val obj = JSONObject(metadataJson) - if (!obj.has("error")) { + val filenameFallback = obj.optBoolean("metadataFromFilename", false) + if (!obj.has("error") && !filenameFallback) { return obj } } @@ -813,7 +825,11 @@ class MainActivity: FlutterFragmentActivity() { } ?: return null try { - val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName) + val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON( + tempPath, + displayName, + coverCacheKey, + ) if (metadataJson.isBlank()) return null val obj = JSONObject(metadataJson) return if (obj.has("error")) null else obj @@ -1190,6 +1206,11 @@ class MainActivity: FlutterFragmentActivity() { val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null + val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueDoc.lastModified() } + val coverCacheKey = buildLibraryCoverCacheKey( + audioDoc.uri.toString(), + audioLastModified, + ) tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) if (tempAudioPath == null) { @@ -1208,11 +1229,12 @@ class MainActivity: FlutterFragmentActivity() { val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } - val cueResultsJson = Gobackend.scanCueSheetForLibrary( + val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey( tempCuePath, tempDir, cueDoc.uri.toString(), - cueLastModified + cueLastModified, + coverCacheKey, ) val cueArray = JSONArray(cueResultsJson) @@ -1264,13 +1286,19 @@ class MainActivity: FlutterFragmentActivity() { val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) + val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } + val stableUri = doc.uri.toString() + val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified) + val metadataObj = readAudioMetadataFromUri( + doc.uri, + name, + fallbackExt, + coverCacheKey, + ) if (metadataObj == null) { errors++ } else { try { - val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } - val stableUri = doc.uri.toString() metadataObj.put("id", buildStableLibraryId(stableUri)) metadataObj.put("filePath", stableUri) metadataObj.put("fileModTime", lastModified) @@ -1538,6 +1566,11 @@ class MainActivity: FlutterFragmentActivity() { val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null + val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueLastModified } + val coverCacheKey = buildLibraryCoverCacheKey( + audioDoc.uri.toString(), + audioLastModified, + ) tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) if (tempAudioPath == null) { @@ -1554,11 +1587,12 @@ class MainActivity: FlutterFragmentActivity() { tempAudioPath = renamedAudio.absolutePath } - val cueResultsJson = Gobackend.scanCueSheetForLibrary( + val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey( tempCuePath, tempDir, cueDoc.uri.toString(), - cueLastModified + cueLastModified, + coverCacheKey, ) val cueArray = JSONArray(cueResultsJson) @@ -1655,13 +1689,19 @@ class MainActivity: FlutterFragmentActivity() { val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) + val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } + val stableUri = doc.uri.toString() + val coverCacheKey = buildLibraryCoverCacheKey(stableUri, safeLastModified) + val metadataObj = readAudioMetadataFromUri( + doc.uri, + name, + fallbackExt, + coverCacheKey, + ) if (metadataObj == null) { errors++ } else { try { - val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } - val stableUri = doc.uri.toString() metadataObj.put("id", buildStableLibraryId(stableUri)) metadataObj.put("filePath", stableUri) metadataObj.put("fileModTime", safeLastModified) diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 8f1b2921..298ac399 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -1620,14 +1620,28 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin } func SaveCoverToCache(filePath, cacheDir string) (string, error) { - return SaveCoverToCacheWithHint(filePath, "", cacheDir) + return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "") } func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) { + return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "") +} + +func resolveLibraryCoverCacheKey(filePath, explicitKey string) string { + explicitKey = strings.TrimSpace(explicitKey) + if explicitKey != "" { + return explicitKey + } + cacheKey := filePath if stat, err := os.Stat(filePath); err == nil { cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano()) } + return cacheKey +} + +func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) { + cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey) hash := hashString(cacheKey) jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash)) diff --git a/go_backend/audio_metadata_cache_test.go b/go_backend/audio_metadata_cache_test.go new file mode 100644 index 00000000..017b31a8 --- /dev/null +++ b/go_backend/audio_metadata_cache_test.go @@ -0,0 +1,34 @@ +package gobackend + +import ( + "os" + "strings" + "testing" +) + +func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) { + t.Parallel() + + const explicitKey = "content://media/external/audio/media/42|123456" + got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey) + if got != explicitKey { + t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got) + } +} + +func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) { + t.Parallel() + + tempFile, err := os.CreateTemp("", "cover-cache-*.flac") + if err != nil { + t.Fatalf("CreateTemp failed: %v", err) + } + tempPath := tempFile.Name() + tempFile.Close() + defer os.Remove(tempPath) + + got := resolveLibraryCoverCacheKey(tempPath, "") + if !strings.HasPrefix(got, tempPath+"|") { + t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got) + } +} diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go index 1a0dfa06..480ea51a 100644 --- a/go_backend/cue_parser.go +++ b/go_backend/cue_parser.go @@ -26,11 +26,11 @@ type CueSheet struct { // CueTrack represents a single track in a cue sheet type CueTrack struct { - Number int `json:"number"` - Title string `json:"title"` - Performer string `json:"performer"` - ISRC string `json:"isrc,omitempty"` - Composer string `json:"composer,omitempty"` + Number int `json:"number"` + Title string `json:"title"` + Performer string `json:"performer"` + ISRC string `json:"isrc,omitempty"` + Composer string `json:"composer,omitempty"` StartTime float64 `json:"start_time"` // INDEX 01 in seconds PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present) } @@ -422,7 +422,7 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult if err != nil { return nil, err } - return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime) + return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime) } // ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters @@ -433,6 +433,17 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult // - fileModTime: if > 0, used as the FileModTime for all results instead of // stat-ing the cuePath on disk (useful when the real file lives behind SAF) func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { + return ScanCueFileForLibraryExtWithCoverCacheKey( + cuePath, + audioDir, + virtualPathPrefix, + fileModTime, + "", + scanTime, + ) +} + +func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) { sheet, err := ParseCueFile(cuePath) if err != nil { return nil, err @@ -441,7 +452,15 @@ func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileM if err != nil { return nil, err } - return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime) + return scanCueSheetForLibrary( + cuePath, + sheet, + audioPath, + virtualPathPrefix, + fileModTime, + coverCacheKey, + scanTime, + ) } func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) { @@ -459,7 +478,7 @@ func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir str return audioPath, nil } -func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { +func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) { if sheet == nil { return nil, fmt.Errorf("cue sheet is nil for %s", cuePath) } @@ -492,7 +511,12 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP coverCacheDir := libraryCoverCacheDir libraryCoverCacheMu.RUnlock() if coverCacheDir != "" { - cp, err := SaveCoverToCache(audioPath, coverCacheDir) + cp, err := SaveCoverToCacheWithHintAndKey( + audioPath, + "", + coverCacheDir, + coverCacheKey, + ) if err == nil && cp != "" { coverPath = cp } diff --git a/go_backend/exports.go b/go_backend/exports.go index b425f46c..2300b183 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -200,6 +200,48 @@ func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest { } } +func buildReEnrichFFmpegMetadata(req reEnrichRequest, lyricsLRC string) map[string]string { + metadata := map[string]string{} + if req.TrackName != "" { + metadata["TITLE"] = req.TrackName + } + if req.ArtistName != "" { + metadata["ARTIST"] = req.ArtistName + } + if req.AlbumName != "" { + metadata["ALBUM"] = req.AlbumName + } + if req.AlbumArtist != "" { + metadata["ALBUMARTIST"] = req.AlbumArtist + } + if req.ReleaseDate != "" { + metadata["DATE"] = req.ReleaseDate + } + if req.ISRC != "" { + metadata["ISRC"] = req.ISRC + } + if req.Genre != "" { + metadata["GENRE"] = req.Genre + } + if req.TrackNumber > 0 { + metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber) + } + if req.DiscNumber > 0 { + metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber) + } + if req.Label != "" { + metadata["ORGANIZATION"] = req.Label + } + if req.Copyright != "" { + metadata["COPYRIGHT"] = req.Copyright + } + if lyricsLRC != "" { + metadata["LYRICS"] = lyricsLRC + metadata["UNSYNCEDLYRICS"] = lyricsLRC + } + return metadata +} + func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata { if len(tracks) == 0 { return nil @@ -1109,6 +1151,26 @@ func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileMod return string(jsonBytes), nil } +func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) { + scanTime := time.Now().UTC().Format(time.RFC3339) + results, err := ScanCueFileForLibraryExtWithCoverCacheKey( + cuePath, + audioDir, + virtualPathPrefix, + fileModTime, + coverCacheKey, + scanTime, + ) + if err != nil { + return "[]", err + } + jsonBytes, err := json.Marshal(results) + if err != nil { + return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err) + } + return string(jsonBytes), nil +} + // EditFileMetadata writes metadata to an audio file. // For FLAC files, uses native Go FLAC library. // For MP3/Opus, returns the metadata map so Dart can use FFmpeg. @@ -2195,36 +2257,14 @@ func ReEnrichFile(requestJSON string) (string, error) { // Don't cleanup cover temp — Dart needs it for FFmpeg embed cleanupCover = false + ffmpegMetadata := buildReEnrichFFmpegMetadata(req, lyricsLRC) + result := map[string]interface{}{ "method": "ffmpeg", "cover_path": coverTempPath, "lyrics": lyricsLRC, "enriched_metadata": enrichedMeta, - "metadata": map[string]string{ - "TITLE": req.TrackName, - "ARTIST": req.ArtistName, - "ALBUM": req.AlbumName, - "ALBUMARTIST": req.AlbumArtist, - "DATE": req.ReleaseDate, - "ISRC": req.ISRC, - "GENRE": req.Genre, - }, - } - if req.TrackNumber > 0 { - result["metadata"].(map[string]string)["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber) - } - if req.DiscNumber > 0 { - result["metadata"].(map[string]string)["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber) - } - if req.Label != "" { - result["metadata"].(map[string]string)["ORGANIZATION"] = req.Label - } - if req.Copyright != "" { - result["metadata"].(map[string]string)["COPYRIGHT"] = req.Copyright - } - if lyricsLRC != "" { - result["metadata"].(map[string]string)["LYRICS"] = lyricsLRC - result["metadata"].(map[string]string)["UNSYNCEDLYRICS"] = lyricsLRC + "metadata": ffmpegMetadata, } jsonBytes, _ := json.Marshal(result) @@ -3468,3 +3508,7 @@ func ReadAudioMetadataJSON(filePath string) (string, error) { func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) { return ReadAudioMetadataWithDisplayName(filePath, displayName) } + +func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) { + return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey) +} diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go index 453a2065..15a4bbb3 100644 --- a/go_backend/exports_test.go +++ b/go_backend/exports_test.go @@ -177,3 +177,48 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) { t.Fatalf("selected track = %q, want candidate with release date", best.ID) } } + +func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) { + req := reEnrichRequest{ + TrackName: "Song", + ArtistName: "Artist", + AlbumName: "Album", + AlbumArtist: "", + ReleaseDate: "", + TrackNumber: 0, + DiscNumber: 0, + ISRC: "", + Genre: "", + Label: "", + Copyright: "", + } + + metadata := buildReEnrichFFmpegMetadata(req, "") + + if metadata["TITLE"] != "Song" { + t.Fatalf("title = %q", metadata["TITLE"]) + } + if metadata["ARTIST"] != "Artist" { + t.Fatalf("artist = %q", metadata["ARTIST"]) + } + if metadata["ALBUM"] != "Album" { + t.Fatalf("album = %q", metadata["ALBUM"]) + } + + for _, key := range []string{ + "ALBUMARTIST", + "DATE", + "TRACKNUMBER", + "DISCNUMBER", + "ISRC", + "GENRE", + "ORGANIZATION", + "COPYRIGHT", + "LYRICS", + "UNSYNCEDLYRICS", + } { + if _, exists := metadata[key]; exists { + t.Fatalf("did not expect key %s in metadata: %#v", key, metadata) + } + } +} diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index c56b57d9..58d8e656 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -13,25 +13,26 @@ import ( ) type LibraryScanResult struct { - ID string `json:"id"` - TrackName string `json:"trackName"` - ArtistName string `json:"artistName"` - AlbumName string `json:"albumName"` - AlbumArtist string `json:"albumArtist,omitempty"` - FilePath string `json:"filePath"` - CoverPath string `json:"coverPath,omitempty"` - ScannedAt string `json:"scannedAt"` - FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds - ISRC string `json:"isrc,omitempty"` - TrackNumber int `json:"trackNumber,omitempty"` - DiscNumber int `json:"discNumber,omitempty"` - Duration int `json:"duration,omitempty"` - 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"` + ID string `json:"id"` + TrackName string `json:"trackName"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + AlbumArtist string `json:"albumArtist,omitempty"` + FilePath string `json:"filePath"` + CoverPath string `json:"coverPath,omitempty"` + ScannedAt string `json:"scannedAt"` + FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds + ISRC string `json:"isrc,omitempty"` + TrackNumber int `json:"trackNumber,omitempty"` + DiscNumber int `json:"discNumber,omitempty"` + Duration int `json:"duration,omitempty"` + 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"` + MetadataFromFilename bool `json:"metadataFromFilename,omitempty"` } type LibraryScanProgress struct { @@ -219,6 +220,7 @@ func ScanLibraryFolder(folderPath string) (string, error) { cueInfo.audioPath, "", fileInfo.modTime, + "", scanTime, ) } else { @@ -269,10 +271,14 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { } func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) { - return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime) + return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime) } func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) { + return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime) +} + +func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) { ext := resolveLibraryAudioExt(filePath, displayNameHint) result := &LibraryScanResult{ @@ -292,7 +298,12 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan coverCacheDir := libraryCoverCacheDir libraryCoverCacheMu.RUnlock() if coverCacheDir != "" { - coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir) + coverPath, err := SaveCoverToCacheWithHintAndKey( + filePath, + displayNameHint, + coverCacheDir, + coverCacheKey, + ) if err == nil && coverPath != "" { result.CoverPath = coverPath } @@ -466,6 +477,7 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str } func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) { + result.MetadataFromFilename = true nameSource := libraryDisplayNameOrPath(filePath, displayNameHint) filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource)) @@ -541,8 +553,18 @@ func ReadAudioMetadata(filePath string) (string, error) { } func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) { + return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "") +} + +func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) { scanTime := time.Now().UTC().Format(time.RFC3339) - result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0) + result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey( + filePath, + displayNameHint, + coverCacheKey, + scanTime, + 0, + ) if err != nil { return "", err } @@ -746,6 +768,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi cueInfo.audioPath, "", f.modTime, + "", scanTime, ) } else { diff --git a/go_backend/library_scan_test.go b/go_backend/library_scan_test.go new file mode 100644 index 00000000..859d3a60 --- /dev/null +++ b/go_backend/library_scan_test.go @@ -0,0 +1,25 @@ +package gobackend + +import "testing" + +func TestScanFromFilenameMarksMetadataFallback(t *testing.T) { + result := &LibraryScanResult{} + + scanned, err := scanFromFilename( + "/proc/self/fd/209", + "189.mp3", + result, + ) + if err != nil { + t.Fatalf("scanFromFilename returned error: %v", err) + } + if !scanned.MetadataFromFilename { + t.Fatal("expected filename fallback marker to be set") + } + if scanned.TrackName != "189" { + t.Fatalf("unexpected track name: %q", scanned.TrackName) + } + if scanned.ArtistName != "Unknown Artist" { + t.Fatalf("unexpected artist name: %q", scanned.ArtistName) + } +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index f64f792b..ebf85394 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strconv" "strings" "sync" @@ -1650,7 +1651,8 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) } var result struct { @@ -1664,6 +1666,234 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo return result.Tracks.Items, nil } +type qobuzTrackSearchCandidate struct { + score int + track QobuzTrack +} + +func qobuzNormalizedSearchText(value string) string { + return normalizeLooseArtistName(value) +} + +func qobuzSearchTokens(value string) []string { + normalized := qobuzNormalizedSearchText(value) + if normalized == "" { + return nil + } + + parts := strings.Fields(normalized) + tokens := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + if len(part) < 2 { + continue + } + if _, ok := seen[part]; ok { + continue + } + seen[part] = struct{}{} + tokens = append(tokens, part) + } + return tokens +} + +func qobuzScoreTrackSearchCandidate(query string, track *QobuzTrack) int { + if track == nil { + return 0 + } + + queryNorm := qobuzNormalizedSearchText(query) + if queryNorm == "" { + return 0 + } + + titleNorm := qobuzNormalizedSearchText(track.Title) + displayNorm := qobuzNormalizedSearchText(qobuzTrackDisplayTitle(track)) + artistNorm := qobuzNormalizedSearchText(qobuzTrackArtistName(track)) + albumNorm := qobuzNormalizedSearchText(strings.TrimSpace(track.Album.Title)) + + score := 0 + + if qobuzTitlesMatch(query, track.Title) || qobuzTitlesMatch(query, qobuzTrackDisplayTitle(track)) { + score += 900 + } + + switch { + case queryNorm == titleNorm, queryNorm == displayNorm: + score += 1200 + case (titleNorm != "" && strings.Contains(titleNorm, queryNorm)) || + (displayNorm != "" && strings.Contains(displayNorm, queryNorm)): + score += 420 + case (titleNorm != "" && strings.Contains(queryNorm, titleNorm)) || + (displayNorm != "" && strings.Contains(queryNorm, displayNorm)): + score += 260 + } + + if artistNorm != "" && strings.Contains(queryNorm, artistNorm) { + score += 180 + } + if albumNorm != "" && strings.Contains(queryNorm, albumNorm) { + score += 100 + } + + for _, token := range qobuzSearchTokens(query) { + switch { + case strings.Contains(titleNorm, token), strings.Contains(displayNorm, token): + score += 180 + case strings.Contains(artistNorm, token): + score += 70 + case strings.Contains(albumNorm, token): + score += 35 + } + } + + if track.ISRC != "" { + score += 15 + } + if track.MaximumBitDepth >= 24 { + score += 10 + } + if track.MaximumSamplingRate >= 88.2 { + score += 10 + } + + return score +} + +func selectQobuzTracksFromAlbumSearchResults( + query string, + limit int, + albumSummaries []qobuzAlbumDetails, + loadAlbum func(string) (*qobuzAlbumDetails, error), +) ([]QobuzTrack, error) { + if strings.TrimSpace(query) == "" { + return nil, fmt.Errorf("empty qobuz album-search fallback query") + } + if len(albumSummaries) == 0 { + return nil, fmt.Errorf("album search returned no albums") + } + + candidates := make([]qobuzTrackSearchCandidate, 0, limit) + seenTrackIDs := make(map[int64]struct{}) + + for _, summary := range albumSummaries { + albumID := strings.TrimSpace(summary.ID) + if albumID == "" { + continue + } + + album, err := loadAlbum(albumID) + if err != nil || album == nil { + continue + } + + for i := range album.Tracks.Items { + track := album.Tracks.Items[i] + track.Album.ID = album.ID + track.Album.QobuzID = album.QobuzID + track.Album.Title = album.Title + track.Album.ReleaseDate = album.ReleaseDateOriginal + track.Album.TracksCount = album.TracksCount + track.Album.ProductType = album.ProductType + track.Album.ReleaseType = album.ReleaseType + track.Album.Artist.ID = album.Artist.ID + track.Album.Artist.Name = album.Artist.Name + track.Album.Artists = album.Artists + track.Album.Image = album.Image + + if track.ID > 0 { + if _, ok := seenTrackIDs[track.ID]; ok { + continue + } + seenTrackIDs[track.ID] = struct{}{} + } + + score := qobuzScoreTrackSearchCandidate(query, &track) + if score <= 0 { + continue + } + + candidates = append(candidates, qobuzTrackSearchCandidate{ + score: score, + track: track, + }) + } + } + + if len(candidates) == 0 { + return nil, fmt.Errorf("album-search fallback returned no scored track candidates") + } + + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].score != candidates[j].score { + return candidates[i].score > candidates[j].score + } + if candidates[i].track.MaximumBitDepth != candidates[j].track.MaximumBitDepth { + return candidates[i].track.MaximumBitDepth > candidates[j].track.MaximumBitDepth + } + return candidates[i].track.ID < candidates[j].track.ID + }) + + if limit > 0 && len(candidates) > limit { + candidates = candidates[:limit] + } + + tracks := make([]QobuzTrack, 0, len(candidates)) + for _, candidate := range candidates { + tracks = append(tracks, candidate.track) + } + return tracks, nil +} + +func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit int) ([]QobuzTrack, error) { + albumLimit := limit + if albumLimit < 3 { + albumLimit = 3 + } + if albumLimit > 8 { + albumLimit = 8 + } + + searchURL := fmt.Sprintf( + "https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s", + url.QueryEscape(strings.TrimSpace(query)), + albumLimit, + q.appID, + ) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + resp, err := DoRequestWithUserAgent(q.client, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("album search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var albumResp struct { + Albums struct { + Items []qobuzAlbumDetails `json:"items"` + } `json:"albums"` + } + if err := json.NewDecoder(resp.Body).Decode(&albumResp); err != nil { + return nil, err + } + + return selectQobuzTracksFromAlbumSearchResults( + query, + limit, + albumResp.Albums.Items, + q.getAlbumDetails, + ) +} + func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 { matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1) if len(matches) == 0 { @@ -1741,9 +1971,18 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int) if len(apiTracks) > 0 { return apiTracks, nil } - GoLog("[Qobuz] API search returned 0 results for '%s', trying store fallback\n", query) + GoLog("[Qobuz] API search returned 0 results for '%s', trying album-search fallback\n", query) } else { - GoLog("[Qobuz] API search failed for '%s': %v. Trying store fallback.\n", query, apiErr) + GoLog("[Qobuz] API search failed for '%s': %v. Trying album-search fallback.\n", query, apiErr) + } + + albumTracks, albumErr := q.searchQobuzTracksViaAlbumSearch(query, limit) + if albumErr == nil && len(albumTracks) > 0 { + GoLog("[Qobuz] Album-search fallback returned %d candidate tracks for '%s'\n", len(albumTracks), query) + return albumTracks, nil + } + if albumErr != nil { + GoLog("[Qobuz] Album-search fallback failed for '%s': %v. Trying store fallback.\n", query, albumErr) } storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit) @@ -1752,10 +1991,21 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int) return storeTracks, nil } - if apiErr != nil && storeErr != nil { - return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr) + if apiErr != nil && albumErr != nil && storeErr != nil { + return nil, fmt.Errorf( + "api search failed (%v); album-search fallback failed (%v); store fallback failed (%v)", + apiErr, + albumErr, + storeErr, + ) + } + if albumErr == nil && len(albumTracks) == 0 && storeErr != nil { + return nil, storeErr } if storeErr != nil { + if albumErr != nil { + return nil, albumErr + } return nil, storeErr } return nil, fmt.Errorf("no tracks found for query: %s", query) diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index 572a055f..2fc43912 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -5,6 +5,21 @@ import ( "testing" ) +func buildTestQobuzAlbum(id, title, artist string, tracks ...QobuzTrack) *qobuzAlbumDetails { + album := &qobuzAlbumDetails{ + ID: id, + Title: title, + ReleaseDateOriginal: "2013-05-20", + TracksCount: len(tracks), + ProductType: "album", + ReleaseType: "album", + } + album.Artist = qobuzArtistRef{ID: 1, Name: artist} + album.Artists = []qobuzArtistRef{{ID: 1, Name: artist}} + album.Tracks.Items = tracks + return album +} + func TestParseQobuzURL(t *testing.T) { tests := []struct { name string @@ -276,6 +291,68 @@ func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack { return track } +func TestSelectQobuzTracksFromAlbumSearchResultsPrefersMatchingTrack(t *testing.T) { + summaries := []qobuzAlbumDetails{ + {ID: "album-a"}, + {ID: "album-b"}, + } + + match := *testQobuzTrack(1, "Get Lucky", "Daft Punk", 369) + other := *testQobuzTrack(2, "Fragments of Time", "Daft Punk", 280) + fallback := *testQobuzTrack(3, "Da Funk", "Daft Punk", 330) + + albums := map[string]*qobuzAlbumDetails{ + "album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", match, other), + "album-b": buildTestQobuzAlbum("album-b", "Homework", "Daft Punk", fallback), + } + + tracks, err := selectQobuzTracksFromAlbumSearchResults( + "daft punk get lucky", + 3, + summaries, + func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tracks) == 0 { + t.Fatal("expected tracks, got none") + } + if tracks[0].ID != 1 { + t.Fatalf("expected Get Lucky to rank first, got track id %d", tracks[0].ID) + } +} + +func TestSelectQobuzTracksFromAlbumSearchResultsDedupesTracks(t *testing.T) { + summaries := []qobuzAlbumDetails{ + {ID: "album-a"}, + {ID: "album-b"}, + } + + shared := *testQobuzTrack(42, "Get Lucky", "Daft Punk", 369) + + albums := map[string]*qobuzAlbumDetails{ + "album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", shared), + "album-b": buildTestQobuzAlbum("album-b", "Random Access Memories Deluxe", "Daft Punk", shared), + } + + tracks, err := selectQobuzTracksFromAlbumSearchResults( + "daft punk get lucky", + 5, + summaries, + func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tracks) != 1 { + t.Fatalf("expected 1 deduped track, got %d", len(tracks)) + } + if tracks[0].ID != 42 { + t.Fatalf("unexpected deduped track id: %d", tracks[0].ID) + } +} + func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) { origGetTrackByID := qobuzGetTrackByIDFunc origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4adf7a37..a5f9550c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3478,6 +3478,42 @@ abstract class AppLocalizations { /// **'Format'** String get libraryFilterFormat; + /// Filter section - metadata completeness + /// + /// In en, this message translates to: + /// **'Metadata'** + String get libraryFilterMetadata; + + /// Filter option - items with complete metadata + /// + /// In en, this message translates to: + /// **'Complete metadata'** + String get libraryFilterMetadataComplete; + + /// Filter option - items missing any tracked metadata field + /// + /// In en, this message translates to: + /// **'Missing any metadata'** + String get libraryFilterMetadataMissingAny; + + /// Filter option - items missing release year/date + /// + /// In en, this message translates to: + /// **'Missing year'** + String get libraryFilterMetadataMissingYear; + + /// Filter option - items missing genre + /// + /// In en, this message translates to: + /// **'Missing genre'** + String get libraryFilterMetadataMissingGenre; + + /// Filter option - items missing album artist + /// + /// In en, this message translates to: + /// **'Missing album artist'** + String get libraryFilterMetadataMissingAlbumArtist; + /// Filter section - sort order /// /// In en, this message translates to: @@ -3496,6 +3532,30 @@ abstract class AppLocalizations { /// **'Oldest'** String get libraryFilterSortOldest; + /// Sort option - album ascending + /// + /// In en, this message translates to: + /// **'Album (A-Z)'** + String get libraryFilterSortAlbumAsc; + + /// Sort option - album descending + /// + /// In en, this message translates to: + /// **'Album (Z-A)'** + String get libraryFilterSortAlbumDesc; + + /// Sort option - genre ascending + /// + /// In en, this message translates to: + /// **'Genre (A-Z)'** + String get libraryFilterSortGenreAsc; + + /// Sort option - genre descending + /// + /// In en, this message translates to: + /// **'Genre (Z-A)'** + String get libraryFilterSortGenreDesc; + /// Relative time - less than a minute ago /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5c9eedd0..a94b7036 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1930,6 +1930,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sortieren'; @@ -1939,6 +1957,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryFilterSortOldest => 'Älteste'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Gerade eben'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e5cb5cbb..a7c4b46a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1902,6 +1902,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1911,6 +1929,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index fa0eed76..e394b145 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1902,6 +1902,24 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1911,6 +1929,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 32c085a6..fa2884db 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1904,6 +1904,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1913,6 +1931,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index ff3ca387..914e06c4 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1902,6 +1902,24 @@ class AppLocalizationsHi extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1911,6 +1929,18 @@ class AppLocalizationsHi extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 89e25ab4..e2ae27f5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1912,6 +1912,24 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1921,6 +1939,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 4a44ecde..bcb88200 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1889,6 +1889,24 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryFilterFormat => '形式'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1898,6 +1916,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 23416c5d..4da21816 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1882,6 +1882,24 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1891,6 +1909,18 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 60bda8f9..62eb07bd 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1902,6 +1902,24 @@ class AppLocalizationsNl extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1911,6 +1929,18 @@ class AppLocalizationsNl extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index c1f42368..b3e302db 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1902,6 +1902,24 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1911,6 +1929,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d411320a..d19fce4d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1948,6 +1948,24 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryFilterFormat => 'Формат'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Сортировка'; @@ -1957,6 +1975,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryFilterSortOldest => 'Старые'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Только что'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 4bc02ea7..41b44b07 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1908,6 +1908,24 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1917,6 +1935,18 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index cc17d8dc..c4cb90b7 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1902,6 +1902,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get libraryFilterFormat => 'Format'; + @override + String get libraryFilterMetadata => 'Metadata'; + + @override + String get libraryFilterMetadataComplete => 'Complete metadata'; + + @override + String get libraryFilterMetadataMissingAny => 'Missing any metadata'; + + @override + String get libraryFilterMetadataMissingYear => 'Missing year'; + + @override + String get libraryFilterMetadataMissingGenre => 'Missing genre'; + + @override + String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist'; + @override String get libraryFilterSort => 'Sort'; @@ -1911,6 +1929,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get libraryFilterSortOldest => 'Oldest'; + @override + String get libraryFilterSortAlbumAsc => 'Album (A-Z)'; + + @override + String get libraryFilterSortAlbumDesc => 'Album (Z-A)'; + + @override + String get libraryFilterSortGenreAsc => 'Genre (A-Z)'; + + @override + String get libraryFilterSortGenreDesc => 'Genre (Z-A)'; + @override String get timeJustNow => 'Just now'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 8a5de0d6..26f38441 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2513,6 +2513,30 @@ "@libraryFilterFormat": { "description": "Filter section - file format" }, + "libraryFilterMetadata": "Metadata", + "@libraryFilterMetadata": { + "description": "Filter section - metadata completeness" + }, + "libraryFilterMetadataComplete": "Complete metadata", + "@libraryFilterMetadataComplete": { + "description": "Filter option - items with complete metadata" + }, + "libraryFilterMetadataMissingAny": "Missing any metadata", + "@libraryFilterMetadataMissingAny": { + "description": "Filter option - items missing any tracked metadata field" + }, + "libraryFilterMetadataMissingYear": "Missing year", + "@libraryFilterMetadataMissingYear": { + "description": "Filter option - items missing release year/date" + }, + "libraryFilterMetadataMissingGenre": "Missing genre", + "@libraryFilterMetadataMissingGenre": { + "description": "Filter option - items missing genre" + }, + "libraryFilterMetadataMissingAlbumArtist": "Missing album artist", + "@libraryFilterMetadataMissingAlbumArtist": { + "description": "Filter option - items missing album artist" + }, "libraryFilterSort": "Sort", "@libraryFilterSort": { "description": "Filter section - sort order" @@ -2525,6 +2549,22 @@ "@libraryFilterSortOldest": { "description": "Sort option - oldest first" }, + "libraryFilterSortAlbumAsc": "Album (A-Z)", + "@libraryFilterSortAlbumAsc": { + "description": "Sort option - album ascending" + }, + "libraryFilterSortAlbumDesc": "Album (Z-A)", + "@libraryFilterSortAlbumDesc": { + "description": "Sort option - album descending" + }, + "libraryFilterSortGenreAsc": "Genre (A-Z)", + "@libraryFilterSortGenreAsc": { + "description": "Sort option - genre ascending" + }, + "libraryFilterSortGenreDesc": "Genre (Z-A)", + "@libraryFilterSortGenreDesc": { + "description": "Sort option - genre descending" + }, "timeJustNow": "Just now", "@timeJustNow": { "description": "Relative time - less than a minute ago" diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 3e90f0fc..5e35b76b 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -339,6 +339,7 @@ class LocalLibraryNotifier extends Notifier { scanWasCancelled: false, excludedDownloadedCount: skippedDownloads, ); + await _pruneLibraryCoverCache(persistedItems); _log.i( 'Full scan complete: ${persistedItems.length} tracks found, ' @@ -815,6 +816,46 @@ class LocalLibraryNotifier extends Notifier { _log.i('Library cleared'); } + Future _pruneLibraryCoverCache(Iterable items) async { + try { + final appSupportDir = await getApplicationSupportDirectory(); + final libraryCoverDir = Directory('${appSupportDir.path}/library_covers'); + if (!await libraryCoverDir.exists()) { + return; + } + + final referencedCoverPaths = items + .map((item) => item.coverPath) + .whereType() + .where((path) => path.isNotEmpty) + .toSet(); + + var deletedCount = 0; + await for (final entity in libraryCoverDir.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File || referencedCoverPaths.contains(entity.path)) { + continue; + } + try { + await entity.delete(); + deletedCount++; + } catch (e) { + _log.w( + 'Failed deleting stale library cover cache ${entity.path}: $e', + ); + } + } + + if (deletedCount > 0) { + _log.i('Pruned $deletedCount stale library cover cache files'); + } + } catch (e) { + _log.w('Failed pruning library cover cache: $e'); + } + } + Future removeItem(String id) async { await _db.delete(id); state = state.copyWith( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 46ab17a9..a898fb08 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -124,6 +124,12 @@ class UnifiedLibraryItem { coverUrl != null || (localCoverPath != null && localCoverPath!.isNotEmpty); + String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist; + + String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate; + + String? get genre => historyItem?.genre ?? localItem?.genre; + String get searchKey => '${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}'; String get albumKey => @@ -319,6 +325,7 @@ class _QueueGroupedAlbumFilterRequest { final String? filterSource; final String? filterQuality; final String? filterFormat; + final String? filterMetadata; final String sortMode; const _QueueGroupedAlbumFilterRequest({ @@ -326,6 +333,7 @@ class _QueueGroupedAlbumFilterRequest { required this.filterSource, required this.filterQuality, required this.filterFormat, + required this.filterMetadata, required this.sortMode, }); @@ -337,6 +345,7 @@ class _QueueGroupedAlbumFilterRequest { filterSource == other.filterSource && filterQuality == other.filterQuality && filterFormat == other.filterFormat && + filterMetadata == other.filterMetadata && sortMode == other.sortMode; @override @@ -345,6 +354,7 @@ class _QueueGroupedAlbumFilterRequest { filterSource, filterQuality, filterFormat, + filterMetadata, sortMode, ); } @@ -358,6 +368,161 @@ String _queueFileExtLower(String filePath) { return filePath.substring(dotIndex + 1).toLowerCase(); } +bool _queueHasMetadataValue(String? value) { + return value != null && value.trim().isNotEmpty; +} + +String _queueNormalizedMetadataValue(String? value) { + return value?.trim().toLowerCase() ?? ''; +} + +DateTime? _queueParseReleaseDate(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return null; + } + + final parsed = DateTime.tryParse(trimmed); + if (parsed != null) { + return parsed; + } + + final yearMatch = RegExp(r'(\d{4})').firstMatch(trimmed); + if (yearMatch == null) { + return null; + } + + final year = int.tryParse(yearMatch.group(1)!); + if (year == null || year <= 0) { + return null; + } + return DateTime(year); +} + +bool _queueMatchesMetadataFilter({ + required String? filterMetadata, + required String? albumArtist, + required String? releaseDate, + required String? genre, +}) { + if (filterMetadata == null) { + return true; + } + + final hasAlbumArtist = _queueHasMetadataValue(albumArtist); + final hasReleaseDate = _queueParseReleaseDate(releaseDate) != null; + final hasGenre = _queueHasMetadataValue(genre); + final isComplete = hasAlbumArtist && hasReleaseDate && hasGenre; + + switch (filterMetadata) { + case 'complete': + return isComplete; + case 'missing-any': + return !isComplete; + case 'missing-year': + return !hasReleaseDate; + case 'missing-genre': + return !hasGenre; + case 'missing-album-artist': + return !hasAlbumArtist; + default: + return true; + } +} + +bool _queueUnifiedItemMatchesMetadataFilter( + UnifiedLibraryItem item, + String? filterMetadata, +) { + return _queueMatchesMetadataFilter( + filterMetadata: filterMetadata, + albumArtist: item.albumArtist, + releaseDate: item.releaseDate, + genre: item.genre, + ); +} + +int _queueCompareOptionalText( + String? left, + String? right, { + bool descending = false, +}) { + final normalizedLeft = _queueNormalizedMetadataValue(left); + final normalizedRight = _queueNormalizedMetadataValue(right); + final leftEmpty = normalizedLeft.isEmpty; + final rightEmpty = normalizedRight.isEmpty; + + if (leftEmpty && rightEmpty) { + return 0; + } + if (leftEmpty) { + return 1; + } + if (rightEmpty) { + return -1; + } + + final comparison = normalizedLeft.compareTo(normalizedRight); + return descending ? -comparison : comparison; +} + +int _queueCompareOptionalDate( + DateTime? left, + DateTime? right, { + bool descending = false, +}) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + + final comparison = left.compareTo(right); + return descending ? -comparison : comparison; +} + +DateTime? _queueGroupedAlbumReleaseDate(_GroupedAlbum album) { + for (final track in album.tracks) { + final releaseDate = _queueParseReleaseDate(track.releaseDate); + if (releaseDate != null) { + return releaseDate; + } + } + return null; +} + +DateTime? _queueGroupedLocalAlbumReleaseDate(_GroupedLocalAlbum album) { + for (final track in album.tracks) { + final releaseDate = _queueParseReleaseDate(track.releaseDate); + if (releaseDate != null) { + return releaseDate; + } + } + return null; +} + +String? _queueGroupedAlbumGenre(_GroupedAlbum album) { + for (final track in album.tracks) { + if (_queueHasMetadataValue(track.genre)) { + return track.genre; + } + } + return null; +} + +String? _queueGroupedLocalAlbumGenre(_GroupedLocalAlbum album) { + for (final track in album.tracks) { + if (_queueHasMetadataValue(track.genre)) { + return track.genre; + } + } + return null; +} + String? _queueLocalQualityLabel(LocalLibraryItem item) { if (item.bitrate != null && item.bitrate! > 0) { return '${item.bitrate}kbps'; @@ -519,6 +684,7 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums( if (request.filterSource == null && request.filterQuality == null && request.filterFormat == null && + request.filterMetadata == null && request.searchQuery.isEmpty && request.sortMode == 'latest') { return albums; @@ -531,7 +697,9 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums( continue; } - if (request.filterQuality != null || request.filterFormat != null) { + if (request.filterQuality != null || + request.filterFormat != null || + request.filterMetadata != null) { var hasMatchingTrack = false; for (final track in album.tracks) { if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) { @@ -540,6 +708,14 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums( if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { continue; } + if (!_queueMatchesMetadataFilter( + filterMetadata: request.filterMetadata, + albumArtist: track.albumArtist, + releaseDate: track.releaseDate, + genre: track.genre, + )) { + continue; + } hasMatchingTrack = true; break; } @@ -552,6 +728,29 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums( switch (request.sortMode) { case 'oldest': result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload)); + case 'artist-asc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'artist-desc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); case 'a-z': result.sort( (a, b) => @@ -562,6 +761,64 @@ List<_GroupedAlbum> _queueFilterGroupedAlbums( (a, b) => b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), ); + case 'album-asc': + result.sort( + (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), + ); + case 'album-desc': + result.sort( + (a, b) => _queueCompareOptionalText( + a.albumName, + b.albumName, + descending: true, + ), + ); + case 'release-oldest': + result.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueGroupedAlbumReleaseDate(a), + _queueGroupedAlbumReleaseDate(b), + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'release-newest': + result.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueGroupedAlbumReleaseDate(a), + _queueGroupedAlbumReleaseDate(b), + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'genre-asc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + _queueGroupedAlbumGenre(a), + _queueGroupedAlbumGenre(b), + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'genre-desc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + _queueGroupedAlbumGenre(a), + _queueGroupedAlbumGenre(b), + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); default: break; } @@ -576,6 +833,7 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( if (request.filterSource == null && request.filterQuality == null && request.filterFormat == null && + request.filterMetadata == null && request.searchQuery.isEmpty && request.sortMode == 'latest') { return albums; @@ -588,7 +846,9 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( continue; } - if (request.filterQuality != null || request.filterFormat != null) { + if (request.filterQuality != null || + request.filterFormat != null || + request.filterMetadata != null) { var hasMatchingTrack = false; for (final track in album.tracks) { if (!_queuePassesQualityFilter( @@ -600,6 +860,14 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) { continue; } + if (!_queueMatchesMetadataFilter( + filterMetadata: request.filterMetadata, + albumArtist: track.albumArtist, + releaseDate: track.releaseDate, + genre: track.genre, + )) { + continue; + } hasMatchingTrack = true; break; } @@ -612,6 +880,29 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( switch (request.sortMode) { case 'oldest': result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned)); + case 'artist-asc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'artist-desc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); case 'a-z': result.sort( (a, b) => @@ -622,6 +913,64 @@ List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums( (a, b) => b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()), ); + case 'album-asc': + result.sort( + (a, b) => _queueCompareOptionalText(a.albumName, b.albumName), + ); + case 'album-desc': + result.sort( + (a, b) => _queueCompareOptionalText( + a.albumName, + b.albumName, + descending: true, + ), + ); + case 'release-oldest': + result.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueGroupedLocalAlbumReleaseDate(a), + _queueGroupedLocalAlbumReleaseDate(b), + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'release-newest': + result.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueGroupedLocalAlbumReleaseDate(a), + _queueGroupedLocalAlbumReleaseDate(b), + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'genre-asc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + _queueGroupedLocalAlbumGenre(a), + _queueGroupedLocalAlbumGenre(b), + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); + case 'genre-desc': + result.sort((a, b) { + final comparison = _queueCompareOptionalText( + _queueGroupedLocalAlbumGenre(a), + _queueGroupedLocalAlbumGenre(b), + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.albumName, b.albumName); + }); default: break; } @@ -781,10 +1130,12 @@ class _QueueTabState extends ConsumerState { String? _filterCacheSource; String? _filterCacheQuality; String? _filterCacheFormat; + String? _filterCacheMetadata; String _filterCacheSortMode = 'latest'; String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg' + String? _filterMetadata; // null = all, 'complete', 'missing-*' String _sortMode = 'latest'; // 'latest', 'oldest', 'a-z', 'z-a' double _effectiveTextScale() { @@ -871,6 +1222,7 @@ class _QueueTabState extends ConsumerState { _filterCacheSource == _filterSource && _filterCacheQuality == _filterQuality && _filterCacheFormat == _filterFormat && + _filterCacheMetadata == _filterMetadata && _filterCacheSortMode == _sortMode; if (isCacheValid) { @@ -886,6 +1238,7 @@ class _QueueTabState extends ConsumerState { _filterCacheSource = _filterSource; _filterCacheQuality = _filterQuality; _filterCacheFormat = _filterFormat; + _filterCacheMetadata = _filterMetadata; _filterCacheSortMode = _sortMode; } @@ -1868,6 +2221,7 @@ class _QueueTabState extends ConsumerState { if (_filterSource != null) count++; if (_filterQuality != null) count++; if (_filterFormat != null) count++; + if (_filterMetadata != null) count++; return count; } @@ -1876,6 +2230,7 @@ class _QueueTabState extends ConsumerState { _filterSource = null; _filterQuality = null; _filterFormat = null; + _filterMetadata = null; _sortMode = 'latest'; _unifiedItemsCache.clear(); _invalidateFilterContentCache(); @@ -1931,6 +2286,13 @@ class _QueueTabState extends ConsumerState { if (ext != _filterFormat) return false; } + if (!_queueUnifiedItemMatchesMetadataFilter( + item, + _filterMetadata, + )) { + return false; + } + return true; }) .toList(growable: false); @@ -1957,6 +2319,95 @@ class _QueueTabState extends ConsumerState { (a, b) => b.trackName.toLowerCase().compareTo(a.trackName.toLowerCase()), ); + case 'artist-asc': + sorted.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.trackName, b.trackName); + }); + case 'artist-desc': + sorted.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.artistName, + b.artistName, + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.trackName, b.trackName); + }); + case 'album-asc': + sorted.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.albumName, + b.albumName, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.trackName, b.trackName); + }); + case 'album-desc': + sorted.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.albumName, + b.albumName, + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.trackName, b.trackName); + }); + case 'release-oldest': + sorted.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueParseReleaseDate(a.releaseDate), + _queueParseReleaseDate(b.releaseDate), + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.trackName, b.trackName); + }); + case 'release-newest': + sorted.sort((a, b) { + final comparison = _queueCompareOptionalDate( + _queueParseReleaseDate(a.releaseDate), + _queueParseReleaseDate(b.releaseDate), + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.trackName, b.trackName); + }); + case 'genre-asc': + sorted.sort((a, b) { + final comparison = _queueCompareOptionalText(a.genre, b.genre); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.trackName, b.trackName); + }); + case 'genre-desc': + sorted.sort((a, b) { + final comparison = _queueCompareOptionalText( + a.genre, + b.genre, + descending: true, + ); + if (comparison != 0) { + return comparison; + } + return _queueCompareOptionalText(a.trackName, b.trackName); + }); } return sorted; } @@ -1982,6 +2433,7 @@ class _QueueTabState extends ConsumerState { String? tempSource = _filterSource; String? tempQuality = _filterQuality; String? tempFormat = _filterFormat; + String? tempMetadata = _filterMetadata; String tempSortMode = _sortMode; showModalBottomSheet( @@ -2034,6 +2486,7 @@ class _QueueTabState extends ConsumerState { tempSource = null; tempQuality = null; tempFormat = null; + tempMetadata = null; tempSortMode = 'latest'; }); }, @@ -2147,6 +2600,76 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(height: 16), + Text( + context.l10n.libraryFilterMetadata, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilterChip( + label: Text(context.l10n.libraryFilterAll), + selected: tempMetadata == null, + onSelected: (_) => + setSheetState(() => tempMetadata = null), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterMetadataComplete, + ), + selected: tempMetadata == 'complete', + onSelected: (_) => setSheetState( + () => tempMetadata = 'complete', + ), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterMetadataMissingAny, + ), + selected: tempMetadata == 'missing-any', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-any', + ), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterMetadataMissingYear, + ), + selected: tempMetadata == 'missing-year', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-year', + ), + ), + FilterChip( + label: Text( + context + .l10n + .libraryFilterMetadataMissingGenre, + ), + selected: tempMetadata == 'missing-genre', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-genre', + ), + ), + FilterChip( + label: Text( + context + .l10n + .libraryFilterMetadataMissingAlbumArtist, + ), + selected: + tempMetadata == 'missing-album-artist', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-album-artist', + ), + ), + ], + ), + const SizedBox(height: 16), + Text( context.l10n.libraryFilterSort, style: Theme.of(context).textTheme.titleSmall @@ -2175,17 +2698,81 @@ class _QueueTabState extends ConsumerState { ), ), FilterChip( - label: Text(context.l10n.sortAlphaAsc), + label: Text(context.l10n.searchSortTitleAZ), selected: tempSortMode == 'a-z', onSelected: (_) => setSheetState(() => tempSortMode = 'a-z'), ), FilterChip( - label: Text(context.l10n.sortAlphaDesc), + label: Text(context.l10n.searchSortTitleZA), selected: tempSortMode == 'z-a', onSelected: (_) => setSheetState(() => tempSortMode = 'z-a'), ), + FilterChip( + label: Text(context.l10n.searchSortArtistAZ), + selected: tempSortMode == 'artist-asc', + onSelected: (_) => setSheetState( + () => tempSortMode = 'artist-asc', + ), + ), + FilterChip( + label: Text(context.l10n.searchSortArtistZA), + selected: tempSortMode == 'artist-desc', + onSelected: (_) => setSheetState( + () => tempSortMode = 'artist-desc', + ), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterSortAlbumAsc, + ), + selected: tempSortMode == 'album-asc', + onSelected: (_) => setSheetState( + () => tempSortMode = 'album-asc', + ), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterSortAlbumDesc, + ), + selected: tempSortMode == 'album-desc', + onSelected: (_) => setSheetState( + () => tempSortMode = 'album-desc', + ), + ), + FilterChip( + label: Text(context.l10n.searchSortDateNewest), + selected: tempSortMode == 'release-newest', + onSelected: (_) => setSheetState( + () => tempSortMode = 'release-newest', + ), + ), + FilterChip( + label: Text(context.l10n.searchSortDateOldest), + selected: tempSortMode == 'release-oldest', + onSelected: (_) => setSheetState( + () => tempSortMode = 'release-oldest', + ), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterSortGenreAsc, + ), + selected: tempSortMode == 'genre-asc', + onSelected: (_) => setSheetState( + () => tempSortMode = 'genre-asc', + ), + ), + FilterChip( + label: Text( + context.l10n.libraryFilterSortGenreDesc, + ), + selected: tempSortMode == 'genre-desc', + onSelected: (_) => setSheetState( + () => tempSortMode = 'genre-desc', + ), + ), ], ), const SizedBox(height: 24), @@ -2198,6 +2785,7 @@ class _QueueTabState extends ConsumerState { _filterSource = tempSource; _filterQuality = tempQuality; _filterFormat = tempFormat; + _filterMetadata = tempMetadata; _sortMode = tempSortMode; _unifiedItemsCache.clear(); _invalidateFilterContentCache(); @@ -2738,6 +3326,7 @@ class _QueueTabState extends ConsumerState { filterSource: _filterSource, filterQuality: _filterQuality, filterFormat: _filterFormat, + filterMetadata: _filterMetadata, sortMode: _sortMode, ), ), diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 3ab43eb7..b2f90161 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1691,6 +1691,9 @@ class FFmpegService { final key = entry.key.toUpperCase(); final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), ''); final value = entry.value; + if (value.trim().isEmpty) { + continue; + } switch (normalizedKey) { case 'TITLE': @@ -1708,12 +1711,16 @@ class FFmpegService { case 'TRACKNUMBER': case 'TRACK': case 'TRCK': - id3Map['track'] = value; + if (value != '0') { + id3Map['track'] = value; + } break; case 'DISCNUMBER': case 'DISC': case 'TPOS': - id3Map['disc'] = value; + if (value != '0') { + id3Map['disc'] = value; + } break; case 'DATE': case 'YEAR':