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
+24
View File
@@ -3198,6 +3198,10 @@
"@trackReEnrichOnlineSubtitle": {
"description": "Subtitle for re-enrich metadata action for local items"
},
"trackReEnrichBatchSubtitle": "Choose an online or manual update, then review every change before writing",
"@trackReEnrichBatchSubtitle": {
"description": "Subtitle for batch metadata update choices"
},
"trackReEnrichFieldCover": "Cover Art",
"@trackReEnrichFieldCover": {
"description": "Checkbox label for cover art field in re-enrich"
@@ -3250,6 +3254,22 @@
"@trackReEnrichModeReplaceSubtitle": {
"description": "Explanation for the selected-tag batch metadata mode"
},
"trackReEnrichModeManual": "Set common values",
"@trackReEnrichModeManual": {
"description": "Batch metadata mode that applies shared values to every selected track"
},
"trackReEnrichModeManualSubtitle": "Apply the same values to every selected track",
"@trackReEnrichModeManualSubtitle": {
"description": "Explanation for the manual shared-value batch metadata mode"
},
"trackReEnrichManualFieldsTitle": "Values to apply",
"@trackReEnrichManualFieldsTitle": {
"description": "Heading above manual batch metadata inputs"
},
"trackReEnrichManualHint": "Leave a field empty to keep its current value. Track titles, numbers, and ISRCs stay unchanged.",
"@trackReEnrichManualHint": {
"description": "Safety explanation for manual batch metadata inputs"
},
"trackReEnrichFieldsTitle": "Tags to update",
"@trackReEnrichFieldsTitle": {
"description": "Heading above batch re-enrich field checkboxes"
@@ -3316,6 +3336,10 @@
"@trackReEnrichSearching": {
"description": "Snackbar while searching metadata from internet for local items"
},
"trackReEnrichPreparing": "Preparing metadata changes...",
"@trackReEnrichPreparing": {
"description": "Progress message while preparing manual batch metadata changes"
},
"trackReEnrichSuccess": "Metadata re-enriched successfully",
"@trackReEnrichSuccess": {
"description": "Snackbar after successful re-enrichment"
+6
View File
@@ -720,6 +720,10 @@
"trackReEnrichModeMissingSubtitle": "Keep existing values and fill only fields that are empty",
"trackReEnrichModeReplace": "Update selected tags",
"trackReEnrichModeReplaceSubtitle": "Choose which existing values may be replaced by online metadata",
"trackReEnrichModeManual": "Tetapkan nilai yang sama",
"trackReEnrichModeManualSubtitle": "Terapkan nilai yang sama ke setiap lagu yang dipilih",
"trackReEnrichManualFieldsTitle": "Nilai yang akan diterapkan",
"trackReEnrichManualHint": "Biarkan kolom kosong untuk mempertahankan nilai saat ini. Judul, nomor lagu, dan ISRC tidak akan diubah.",
"trackReEnrichFieldsTitle": "Tags to update",
"trackReEnrichReview": "Review changes",
"trackReEnrichReviewTitle": "Review metadata changes",
@@ -2288,6 +2292,7 @@
"@trackReEnrichOnlineSubtitle": {
"description": "Subtitle for re-enrich metadata action for local items"
},
"trackReEnrichBatchSubtitle": "Pilih pembaruan online atau manual, lalu tinjau setiap perubahan sebelum ditulis",
"nowPlayingNothingPlaying": "Nothing is playing",
"@nowPlayingNothingPlaying": {
"description": "Empty state when no track is currently playing"
@@ -5811,6 +5816,7 @@
"lyricsProviderLyricsPlusDesc": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)",
"dialogSave": "Simpan",
"trackReEnrichSearching": "Searching metadata online...",
"trackReEnrichPreparing": "Menyiapkan perubahan metadata...",
"regionCountryUS": "United States",
"audioAnalysisDescription": "Verify lossless quality with spectrum analysis",
"downloadFilenameFormat": "Format Nama File",
+9 -2
View File
@@ -640,9 +640,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackReEnrichSearching,
title: selection.usesManualValues
? context.l10n.trackReEnrichPreparing
: context.l10n.trackReEnrichSearching,
total: selected.length,
icon: Icons.manage_search,
icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
@@ -658,6 +660,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
);
final updateFields = selection.updateFieldsFor(item);
if (updateFields.isEmpty) continue;
if (selection.usesManualValues) {
final preview = buildManualBatchReEnrichPreview(item, selection);
if (preview != null) previews.add(preview);
continue;
}
try {
final result = await PlatformBridge.reEnrichFile(
buildBatchReEnrichRequest(
+9 -2
View File
@@ -219,9 +219,11 @@ extension _QueueTabBatchActions on _QueueTabState {
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackReEnrichSearching,
title: selection.usesManualValues
? context.l10n.trackReEnrichPreparing
: context.l10n.trackReEnrichSearching,
total: selectedLocalItems.length,
icon: Icons.manage_search,
icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
@@ -237,6 +239,11 @@ extension _QueueTabBatchActions on _QueueTabState {
);
final updateFields = selection.updateFieldsFor(item);
if (updateFields.isEmpty) continue;
if (selection.usesManualValues) {
final preview = buildManualBatchReEnrichPreview(item, selection);
if (preview != null) previews.add(preview);
continue;
}
try {
final result = await PlatformBridge.reEnrichFile(
buildBatchReEnrichRequest(
+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;
}
}
+113 -3
View File
@@ -12,7 +12,7 @@ Future<ReEnrichFieldSelection?> showReEnrichFieldDialog(
context: context,
useRootNavigator: true,
title: AppLocalizations.of(context).trackReEnrich,
subtitle: AppLocalizations.of(context).trackReEnrichOnlineSubtitle,
subtitle: AppLocalizations.of(context).trackReEnrichBatchSubtitle,
maxHeightFactor: 0.9,
builder: (ctx) => _ReEnrichFieldSheet(selectedCount: selectedCount),
);
@@ -28,9 +28,30 @@ class _ReEnrichFieldSheet extends StatefulWidget {
class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
final Set<String> _selected = Set<String>.from(ReEnrichFields.all);
final Map<String, TextEditingController> _manualControllers = {
for (final field in manualBatchMetadataFields)
field: TextEditingController(),
};
ReEnrichBatchMode _mode = ReEnrichBatchMode.missingOnly;
bool get _allSelected => _selected.length == ReEnrichFields.all.length;
bool get _hasManualValues => _manualControllers.values.any(
(controller) => controller.text.trim().isNotEmpty,
);
Map<String, String> get _manualValues => {
for (final entry in _manualControllers.entries)
if (entry.value.text.trim().isNotEmpty)
entry.key: entry.value.text.trim(),
};
@override
void dispose() {
for (final controller in _manualControllers.values) {
controller.dispose();
}
super.dispose();
}
void _toggleAll(bool? value) {
setState(() {
@@ -90,6 +111,29 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
}
}
String _manualLabelFor(String field, AppLocalizations l10n) {
switch (field) {
case 'artist_name':
return l10n.trackArtist;
case 'album_name':
return l10n.trackAlbum;
case 'album_artist':
return l10n.trackAlbumArtist;
case 'release_date':
return l10n.trackReleaseDate;
case 'genre':
return l10n.trackGenre;
case 'composer':
return l10n.editMetadataFieldComposer;
case 'label':
return l10n.trackLabel;
case 'copyright':
return l10n.trackCopyright;
default:
return field;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@@ -146,6 +190,17 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
onTap: () =>
setState(() => _mode = ReEnrichBatchMode.selectedFields),
),
const Divider(height: 1, indent: 56),
ListTile(
leading: const Icon(Icons.edit_note),
title: Text(l10n.trackReEnrichModeManual),
subtitle: Text(l10n.trackReEnrichModeManualSubtitle),
trailing: _mode == ReEnrichBatchMode.manualValues
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () =>
setState(() => _mode = ReEnrichBatchMode.manualValues),
),
],
),
if (_mode == ReEnrichBatchMode.selectedFields) ...[
@@ -185,6 +240,58 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
],
),
],
if (_mode == ReEnrichBatchMode.manualValues) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.trackReEnrichManualFieldsTitle,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
l10n.trackReEnrichManualHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
SettingsGroup(
children: [
for (
var index = 0;
index < manualBatchMetadataFields.length;
index++
) ...[
if (index > 0) const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: TextField(
controller:
_manualControllers[manualBatchMetadataFields[index]],
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: _manualLabelFor(
manualBatchMetadataFields[index],
l10n,
),
border: const OutlineInputBorder(),
isDense: true,
),
),
),
],
],
),
],
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
@@ -192,14 +299,17 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
width: double.infinity,
child: FilledButton.icon(
onPressed:
_mode == ReEnrichBatchMode.selectedFields &&
_selected.isEmpty
(_mode == ReEnrichBatchMode.selectedFields &&
_selected.isEmpty) ||
(_mode == ReEnrichBatchMode.manualValues &&
!_hasManualValues)
? null
: () => Navigator.pop(
context,
ReEnrichFieldSelection(
mode: _mode,
fields: _selected.toList(),
manualValues: _manualValues,
),
),
icon: const Icon(Icons.preview_outlined, size: 18),