mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-20 08:42:16 +02:00
feat(metadata): add safe batch editing and matching
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user