mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-13 05:19:04 +02:00
feat(library): add bulk duplicate cleanup
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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]),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user