feat(metadata): add safe batch editing and matching

This commit is contained in:
zarzet
2026-09-04 16:55:44 +07:00
parent cbeada208b
commit 15ffb3abf5
22 changed files with 578 additions and 51 deletions
+62 -2
View File
@@ -20,13 +20,34 @@ class ReEnrichFields {
];
}
enum ReEnrichBatchMode { isrcOnly, missingOnly, selectedFields }
enum ReEnrichBatchMode { isrcOnly, missingOnly, selectedFields, manualValues }
/// Tags where applying one shared value to multiple tracks is normally safe.
/// Per-track identifiers, titles, and track/disc numbers are deliberately
/// excluded so the batch editor cannot accidentally duplicate them.
const List<String> manualBatchMetadataFields = [
'artist_name',
'album_name',
'album_artist',
'release_date',
'genre',
'composer',
'label',
'copyright',
];
class ReEnrichFieldSelection {
final ReEnrichBatchMode mode;
final List<String> fields;
final Map<String, String> manualValues;
const ReEnrichFieldSelection({required this.mode, this.fields = const []});
const ReEnrichFieldSelection({
required this.mode,
this.fields = const [],
this.manualValues = const {},
});
bool get usesManualValues => mode == ReEnrichBatchMode.manualValues;
List<String> updateFieldsFor(LocalLibraryItem item) {
switch (mode) {
@@ -36,6 +57,15 @@ class ReEnrichFieldSelection {
return fields;
case ReEnrichBatchMode.missingOnly:
return missingReEnrichFields(item);
case ReEnrichBatchMode.manualValues:
return manualValues.entries
.where(
(entry) =>
manualBatchMetadataFields.contains(entry.key) &&
entry.value.trim().isNotEmpty,
)
.map((entry) => entry.key)
.toList(growable: false);
}
}
}
@@ -138,6 +168,36 @@ class BatchReEnrichPreview {
});
}
BatchReEnrichPreview? buildManualBatchReEnrichPreview(
LocalLibraryItem item,
ReEnrichFieldSelection selection,
) {
if (!selection.usesManualValues) return null;
final enrichedMetadata = <String, dynamic>{
for (final entry in selection.manualValues.entries)
if (manualBatchMetadataFields.contains(entry.key) &&
entry.value.trim().isNotEmpty)
entry.key: entry.value.trim(),
};
final updateFields = enrichedMetadata.keys.toList(growable: false);
if (updateFields.isEmpty) return null;
final changes = buildReEnrichMetadataChanges(
item,
enrichedMetadata,
updateFields,
);
if (changes.isEmpty) return null;
return BatchReEnrichPreview(
item: item,
updateFields: updateFields,
enrichedMetadata: enrichedMetadata,
changes: changes,
);
}
String _displayValue(Object? value) {
if (value == null) return '';
if (value is num && value == 0) return '';
+17 -1
View File
@@ -2,6 +2,11 @@ part of 'library_database.dart';
// SQL builders for the queue tab's history+local union queries.
String confirmedMissingLyricsSqlPredicate({
required String hasLyricsExpr,
required String lyricsKnownExpr,
}) => '($lyricsKnownExpr) AND COALESCE($hasLyricsExpr, 0) = 0';
class _QueueOrderTerm {
final String column;
final bool descending;
@@ -398,6 +403,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
isrcExpr: 'h.isrc',
labelExpr: 'h.label',
hasLyricsExpr: 'h.has_lyrics',
lyricsKnownExpr: 'COALESCE(h.lyrics_metadata_scan_version, 0) >= 1',
);
}
@@ -441,6 +447,8 @@ extension _LibraryDbQueueSql on LibraryDatabase {
isrcExpr: 'l.isrc',
labelExpr: 'l.label',
hasLyricsExpr: 'l.has_lyrics',
lyricsKnownExpr:
'COALESCE(l.audio_metadata_scan_version, 0) >= ${LibraryDatabase.audioMetadataScanVersion}',
);
}
@@ -461,6 +469,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
required String isrcExpr,
required String labelExpr,
required String hasLyricsExpr,
required String lyricsKnownExpr,
}) {
final quality = request.quality?.trim().toLowerCase();
if (quality != null && quality.isNotEmpty) {
@@ -546,7 +555,14 @@ extension _LibraryDbQueueSql on LibraryDatabase {
where.add('NOT ($hasLabel)');
break;
case 'missing-lyrics':
where.add('COALESCE($hasLyricsExpr, 0) = 0');
// A default false value on legacy rows means "not scanned yet", not
// "confirmed missing". Only show files whose lyrics probe completed.
where.add(
confirmedMissingLyricsSqlPredicate(
hasLyricsExpr: hasLyricsExpr,
lyricsKnownExpr: lyricsKnownExpr,
),
);
break;
}
}