feat(library): add bulk duplicate cleanup

This commit is contained in:
zarzet
2026-09-04 12:39:03 +07:00
parent 55da453913
commit cbeada208b
6 changed files with 200 additions and 26 deletions
+16
View File
@@ -854,6 +854,22 @@
"@duplicatesKeepBest": {
"description": "Button that deletes all but the highest-quality copy in a duplicate group"
},
"duplicatesKeepBestAll": "Keep best for all",
"@duplicatesKeepBestAll": {
"description": "Button that keeps the highest-quality copy in every duplicate group and deletes the rest"
},
"duplicatesKeepBestAllMessage": "Keep the best copy of {groupCount} {groupCount, plural, =1{track} other{tracks}} and delete {count} lower-quality {count, plural, =1{copy} other{copies}}?\n\nThe deleted files will be removed from storage.",
"@duplicatesKeepBestAllMessage": {
"description": "Confirmation message for keeping the best copy across every duplicate group",
"placeholders": {
"count": {
"type": "int"
},
"groupCount": {
"type": "int"
}
}
},
"duplicatesKeepBestMessage": "Delete {count} lower-quality copies of \"{trackName}\"?",
"@duplicatesKeepBestMessage": {
"description": "Confirmation message for the keep-best action",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"@@locale": "es_ES",
"@@last_modified": "2026-04-28",
"appName": "SpotiFLAC Móvil",
"appName": "SpotiFLAC Mobile",
"@appName": {
"description": "App name - DO NOT TRANSLATE"
},
@@ -2624,7 +2624,7 @@
}
}
},
"tutorialWelcomeTitle": "¡Bienvenido a SpotiFLAC Móvil!",
"tutorialWelcomeTitle": "¡Bienvenido a SpotiFLAC Mobile!",
"@tutorialWelcomeTitle": {
"description": "Tutorial welcome page title"
},
+3 -1
View File
@@ -1551,7 +1551,7 @@
"@searchSortTitleZA": {
"description": "Sort option - title descending"
},
"tutorialLibraryTip2": "Tap a track to play it with the built-in player",
"tutorialLibraryTip2": "Ketuk lagu untuk memutarnya dengan pemutar bawaan",
"@tutorialLibraryTip2": {
"description": "Tutorial library tip 2"
},
@@ -6168,6 +6168,8 @@
"duplicatesTitle": "Duplikat",
"duplicatesEmpty": "Tidak menemukan lagu duplikat.",
"duplicatesKeepBest": "Simpan lagu kualitas terbaik",
"duplicatesKeepBestAll": "Simpan yang terbaik untuk semua",
"duplicatesKeepBestAllMessage": "Simpan salinan terbaik dari {groupCount} lagu dan hapus {count} salinan berkualitas lebih rendah?\n\nFile yang dihapus juga akan dihapus dari penyimpanan.",
"duplicatesKeepBestMessage": "Hapus {count} salinan kualitas rendah lagu \"{trackName}\"?",
"duplicatesDeleteCopyMessage": "Hapus salinan lagu \"{trackName}\"?",
"appearanceForceBlur": "Selalu gunakan efek blur",
+39
View File
@@ -0,0 +1,39 @@
import 'package:spotiflac_android/services/library_database.dart';
class DuplicateCleanupPlan {
final List<IsrcDuplicateEntry> retainedEntries;
final List<IsrcDuplicateEntry> entriesToDelete;
final int groupCount;
const DuplicateCleanupPlan({
required this.retainedEntries,
required this.entriesToDelete,
required this.groupCount,
});
}
/// Keeps the first (highest-quality) entry from every duplicate group and
/// schedules every remaining copy for deletion.
///
/// [LibraryDatabase.findIsrcDuplicateGroups] returns entries best-quality
/// first, so this policy deliberately preserves that ordering contract.
DuplicateCleanupPlan buildKeepBestAllDuplicatePlan(
List<IsrcDuplicateGroup> groups,
) {
final retainedEntries = <IsrcDuplicateEntry>[];
final entriesToDelete = <IsrcDuplicateEntry>[];
var groupCount = 0;
for (final group in groups) {
if (group.entries.length < 2) continue;
groupCount++;
retainedEntries.add(group.entries.first);
entriesToDelete.addAll(group.entries.skip(1));
}
return DuplicateCleanupPlan(
retainedEntries: List.unmodifiable(retainedEntries),
entriesToDelete: List.unmodifiable(entriesToDelete),
groupCount: groupCount,
);
}
+91 -23
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/duplicate_cleanup_policy.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
@@ -41,6 +42,7 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
late final LocalLibraryNotifier _localLibraryNotifier;
late Future<List<IsrcDuplicateGroup>> _groupsFuture;
bool _deletedLocalRows = false;
bool _deleteActionInProgress = false;
@override
void initState() {
@@ -151,37 +153,63 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
_reload();
}
Future<void> _confirmAndDelete({
required String message,
required List<IsrcDuplicateEntry> entries,
required List<IsrcDuplicateEntry> retainedEntries,
}) async {
if (_deleteActionInProgress || entries.isEmpty) return;
setState(() => _deleteActionInProgress = true);
try {
final confirmed = await _confirmDelete(message);
if (confirmed) {
await _deleteEntries(entries, retainedEntries: retainedEntries);
}
} finally {
if (mounted) {
setState(() => _deleteActionInProgress = false);
}
}
}
Future<void> _keepBest(IsrcDuplicateGroup group) async {
final toDelete = group.entries.sublist(1);
final confirmed = await _confirmDelete(
context.l10n.duplicatesKeepBestMessage(
await _confirmAndDelete(
message: context.l10n.duplicatesKeepBestMessage(
toDelete.length,
group.entries.first.trackName,
),
entries: toDelete,
retainedEntries: [group.entries.first],
);
}
Future<void> _keepBestAll(List<IsrcDuplicateGroup> groups) async {
final plan = buildKeepBestAllDuplicatePlan(groups);
await _confirmAndDelete(
message: context.l10n.duplicatesKeepBestAllMessage(
plan.entriesToDelete.length,
plan.groupCount,
),
entries: plan.entriesToDelete,
retainedEntries: plan.retainedEntries,
);
if (confirmed) {
await _deleteEntries(toDelete, retainedEntries: [group.entries.first]);
}
}
Future<void> _deleteSingle(
IsrcDuplicateGroup group,
IsrcDuplicateEntry entry,
) async {
final confirmed = await _confirmDelete(
context.l10n.duplicatesDeleteCopyMessage(entry.trackName),
await _confirmAndDelete(
message: context.l10n.duplicatesDeleteCopyMessage(entry.trackName),
entries: [entry],
retainedEntries: group.entries
.where(
(candidate) =>
candidate.source != entry.source || candidate.id != entry.id,
)
.toList(growable: false),
);
if (confirmed) {
await _deleteEntries(
[entry],
retainedEntries: group.entries
.where(
(candidate) =>
candidate.source != entry.source || candidate.id != entry.id,
)
.toList(growable: false),
);
}
}
@override
@@ -215,14 +243,50 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
final compact = groups.length <= 12;
return ListView.builder(
shrinkWrap: compact,
itemCount: groups.length,
itemBuilder: (context, index) =>
_buildGroup(context, colorScheme, groups[index]),
itemCount: groups.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return _buildKeepBestAllAction(context, colorScheme, groups);
}
return _buildGroup(context, colorScheme, groups[index - 1]);
},
);
},
);
}
Widget _buildKeepBestAllAction(
BuildContext context,
ColorScheme colorScheme,
List<IsrcDuplicateGroup> groups,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _deleteActionInProgress
? null
: () => _keepBestAll(groups),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
),
icon: _deleteActionInProgress
? SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onError,
),
)
: const Icon(Icons.delete_sweep_outlined),
label: Text(context.l10n.duplicatesKeepBestAll),
),
),
);
}
Widget _buildGroup(
BuildContext context,
ColorScheme colorScheme,
@@ -260,7 +324,9 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
),
),
TextButton(
onPressed: () => _keepBest(group),
onPressed: _deleteActionInProgress
? null
: () => _keepBest(group),
child: Text(context.l10n.duplicatesKeepBest),
),
],
@@ -295,7 +361,9 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
: IconButton(
tooltip: context.l10n.dialogDelete,
icon: Icon(Icons.delete_outline, color: colorScheme.error),
onPressed: () => _deleteSingle(group, group.entries[i]),
onPressed: _deleteActionInProgress
? null
: () => _deleteSingle(group, group.entries[i]),
),
),
],
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/duplicate_cleanup_policy.dart';
IsrcDuplicateEntry _entry(String id) {
return IsrcDuplicateEntry(
id: id,
source: 'local',
trackName: 'Track $id',
artistName: 'Artist',
albumName: 'Album',
filePath: '/music/$id.flac',
);
}
void main() {
test('keep-best-all retains one entry and deletes the rest per group', () {
final firstBest = _entry('first-best');
final firstLower = _entry('first-lower');
final firstLowest = _entry('first-lowest');
final secondBest = _entry('second-best');
final secondLower = _entry('second-lower');
final plan = buildKeepBestAllDuplicatePlan([
IsrcDuplicateGroup(
isrc: 'FIRST',
entries: [firstBest, firstLower, firstLowest],
),
IsrcDuplicateGroup(isrc: 'SECOND', entries: [secondBest, secondLower]),
]);
expect(plan.groupCount, 2);
expect(plan.retainedEntries, [firstBest, secondBest]);
expect(plan.entriesToDelete, [firstLower, firstLowest, secondLower]);
});
test('keep-best-all ignores groups that no longer contain duplicates', () {
final onlyCopy = _entry('only-copy');
final plan = buildKeepBestAllDuplicatePlan([
IsrcDuplicateGroup(isrc: 'ONLY', entries: [onlyCopy]),
const IsrcDuplicateGroup(isrc: 'EMPTY', entries: []),
]);
expect(plan.groupCount, 0);
expect(plan.retainedEntries, isEmpty);
expect(plan.entriesToDelete, isEmpty);
});
}