feat: add stable cover cache keys, Qobuz album-search fallback, metadata filters and extended sort options

- Introduce coverCacheKey parameter through Go backend and Kotlin bridge for stable SAF cover caching
- Add MetadataFromFilename flag to skip filename-only metadata and retry via temp-file copy
- Add Qobuz album-search fallback between API search and store scraping
- Extract buildReEnrichFFmpegMetadata to skip empty metadata fields
- Add metadata completeness filter (complete, missing year/genre/album artist)
- Add sort modes: artist, album, release date, genre (asc/desc)
- Prune stale library cover cache files after full scan
- Skip empty values and zero track/disc numbers in FFmpeg metadata
- Add new l10n keys for metadata filter and sort options
This commit is contained in:
zarzet
2026-03-30 11:41:11 +07:00
parent fb90c73f42
commit fabaf0a3ff
28 changed files with 1784 additions and 81 deletions
@@ -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)
+15 -1
View File
@@ -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))
+34
View File
@@ -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)
}
}
+33 -9
View File
@@ -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
}
+69 -25
View File
@@ -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)
}
+45
View File
@@ -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)
}
}
}
+45 -22
View File
@@ -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 {
+25
View File
@@ -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)
}
}
+255 -5
View File
@@ -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)
+77
View File
@@ -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
+60
View File
@@ -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:
+30
View File
@@ -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';
+30
View File
@@ -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';
+30
View File
@@ -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';
+30
View File
@@ -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';
+30
View File
@@ -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';
+30
View File
@@ -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';
+30
View File
@@ -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';
+30
View File
@@ -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';
+30
View File
@@ -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';
+30
View File
@@ -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';
+30
View File
@@ -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 => 'Только что';
+30
View File
@@ -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';
+30
View File
@@ -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';
+40
View File
@@ -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"
+41
View File
@@ -339,6 +339,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
);
await _pruneLibraryCoverCache(persistedItems);
_log.i(
'Full scan complete: ${persistedItems.length} tracks found, '
@@ -815,6 +816,46 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Library cleared');
}
Future<void> _pruneLibraryCoverCache(Iterable<LocalLibraryItem> 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<String>()
.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<void> removeItem(String id) async {
await _db.delete(id);
state = state.copyWith(
+593 -4
View File
@@ -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<QueueTab> {
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<QueueTab> {
_filterCacheSource == _filterSource &&
_filterCacheQuality == _filterQuality &&
_filterCacheFormat == _filterFormat &&
_filterCacheMetadata == _filterMetadata &&
_filterCacheSortMode == _sortMode;
if (isCacheValid) {
@@ -886,6 +1238,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_filterCacheSource = _filterSource;
_filterCacheQuality = _filterQuality;
_filterCacheFormat = _filterFormat;
_filterCacheMetadata = _filterMetadata;
_filterCacheSortMode = _sortMode;
}
@@ -1868,6 +2221,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
_filterSource = null;
_filterQuality = null;
_filterFormat = null;
_filterMetadata = null;
_sortMode = 'latest';
_unifiedItemsCache.clear();
_invalidateFilterContentCache();
@@ -1931,6 +2286,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (ext != _filterFormat) return false;
}
if (!_queueUnifiedItemMatchesMetadataFilter(
item,
_filterMetadata,
)) {
return false;
}
return true;
})
.toList(growable: false);
@@ -1957,6 +2319,95 @@ class _QueueTabState extends ConsumerState<QueueTab> {
(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<QueueTab> {
String? tempSource = _filterSource;
String? tempQuality = _filterQuality;
String? tempFormat = _filterFormat;
String? tempMetadata = _filterMetadata;
String tempSortMode = _sortMode;
showModalBottomSheet<void>(
@@ -2034,6 +2486,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
tempSource = null;
tempQuality = null;
tempFormat = null;
tempMetadata = null;
tempSortMode = 'latest';
});
},
@@ -2147,6 +2600,76 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
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<QueueTab> {
),
),
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<QueueTab> {
_filterSource = tempSource;
_filterQuality = tempQuality;
_filterFormat = tempFormat;
_filterMetadata = tempMetadata;
_sortMode = tempSortMode;
_unifiedItemsCache.clear();
_invalidateFilterContentCache();
@@ -2738,6 +3326,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
filterSource: _filterSource,
filterQuality: _filterQuality,
filterFormat: _filterFormat,
filterMetadata: _filterMetadata,
sortMode: _sortMode,
),
),
+9 -2
View File
@@ -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':