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
+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':