perf(library): batch album deletion and refresh once

This commit is contained in:
zarzet
2026-09-17 17:32:12 +07:00
parent c868aac2cc
commit 1ef81b4530
6 changed files with 224 additions and 14 deletions
+6 -1
View File
@@ -1222,7 +1222,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
Future<void> removeItem(String id) async {
await _db.delete(id);
await removeItems([id]);
}
Future<void> removeItems(Iterable<String> ids) async {
if (ids.isEmpty) return;
await _db.deleteByIds(ids);
await _refreshSummaryFromStorage();
}
+1 -1
View File
@@ -129,9 +129,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
final deleted = await deleteFile(item.filePath);
if (!deleted) return false;
}
await libraryNotifier.removeItem(id);
return true;
},
persistDeletedItems: libraryNotifier.removeItems,
onExitSelectionMode: exitSelectionMode,
);
+26
View File
@@ -3,6 +3,32 @@ import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
/// Deletes a selection atomically without exceeding SQLite's parameter budget.
Future<void> deleteLibraryItemsByIds(Database db, Iterable<String> ids) async {
final uniqueIds = ids.toSet().toList();
if (uniqueIds.isEmpty) return;
await db.transaction((txn) async {
const chunkSize = 500;
for (var start = 0; start < uniqueIds.length; start += chunkSize) {
final chunk = uniqueIds.sublist(
start,
(start + chunkSize).clamp(0, uniqueIds.length),
);
final placeholders = List.filled(chunk.length, '?').join(',');
await txn.delete(
'library_path_keys',
where: 'item_id IN ($placeholders)',
whereArgs: chunk,
);
await txn.delete(
'library',
where: 'id IN ($placeholders)',
whereArgs: chunk,
);
}
});
}
Future<int> pruneUnreferencedLibraryCovers(
Directory directory,
Set<String> referencedPaths,
+6 -9
View File
@@ -1986,15 +1986,12 @@ class LibraryDatabase {
}
Future<void> delete(String id) async {
final db = await database;
await db.transaction((txn) async {
await txn.delete(
'library_path_keys',
where: 'item_id = ?',
whereArgs: [id],
);
await txn.delete('library', where: 'id = ?', whereArgs: [id]);
});
await deleteByIds([id]);
}
Future<void> deleteByIds(Iterable<String> ids) async {
if (ids.isEmpty) return;
await deleteLibraryItemsByIds(await database, ids);
}
Future<int> cleanupMissingFiles({
+13 -3
View File
@@ -8,10 +8,13 @@ import 'package:spotiflac_android/l10n/l10n.dart';
///
/// Returns the number of items [deleteItem] reported deleted, or null if the
/// user cancelled the dialog.
/// [persistDeletedItems] commits successfully deleted IDs once before the UI
/// reports completion, including partial success if a later deletion throws.
Future<int?> confirmAndDeleteTracks({
required BuildContext context,
required List<String> ids,
required Future<bool> Function(String id) deleteItem,
Future<void> Function(List<String> ids)? persistDeletedItems,
required VoidCallback onExitSelectionMode,
}) async {
final confirmed = await showDialog<bool>(
@@ -37,10 +40,17 @@ Future<int?> confirmAndDeleteTracks({
if (confirmed != true || !context.mounted) return null;
var deletedCount = 0;
for (final id in ids) {
if (await deleteItem(id)) deletedCount++;
final deletedIds = <String>[];
try {
for (final id in ids) {
if (await deleteItem(id)) deletedIds.add(id);
}
} finally {
if (deletedIds.isNotEmpty && persistDeletedItems != null) {
await persistDeletedItems(deletedIds);
}
}
final deletedCount = deletedIds.length;
onExitSelectionMode();
+172
View File
@@ -0,0 +1,172 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/services/library_cleanup.dart';
import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
import 'package:sqflite/sqflite.dart';
class _DeleteDatabase implements Database, Transaction {
final rows = <String>{};
final pathKeys = <String>{};
final chunks = <List<Object?>>[];
int transactions = 0;
bool failRows = false;
@override
Future<T> transaction<T>(
Future<T> Function(Transaction txn) action, {
bool? exclusive,
}) async {
transactions++;
final previousRows = Set<String>.of(rows);
final previousKeys = Set<String>.of(pathKeys);
try {
return await action(this);
} catch (_) {
rows
..clear()
..addAll(previousRows);
pathKeys
..clear()
..addAll(previousKeys);
rethrow;
}
}
@override
Future<int> delete(
String table, {
String? where,
List<Object?>? whereArgs,
}) async {
expect(whereArgs!.length, lessThanOrEqualTo(500));
expect('?'.allMatches(where!).length, whereArgs.length);
expect(table, isIn(['library_path_keys', 'library']));
if (table == 'library_path_keys') {
expect(where, startsWith('item_id IN ('));
chunks.add(List.of(whereArgs));
pathKeys.removeAll(whereArgs);
} else {
expect(where, startsWith('id IN ('));
expect(whereArgs, chunks.last);
if (failRows) throw StateError('write failed');
rows.removeAll(whereArgs);
}
return whereArgs.length;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('large selection deletes paired rows in bounded chunks once', () async {
final ids = List.generate(1205, (index) => 'track-$index');
final db = _DeleteDatabase()
..rows.addAll([...ids, 'retained'])
..pathKeys.addAll([...ids, 'retained']);
await deleteLibraryItemsByIds(db, [...ids, ...ids.take(10)]);
expect(db.transactions, 1);
expect(db.chunks.map((chunk) => chunk.length), [500, 500, 205]);
expect(db.chunks.expand((chunk) => chunk), ids);
expect(db.rows, {'retained'});
expect(db.pathKeys, {'retained'});
});
test('empty selection skips storage; failure keeps rows and keys', () async {
final db = _DeleteDatabase()
..rows.add('retained')
..pathKeys.add('retained');
await deleteLibraryItemsByIds(db, []);
expect(db.transactions, 0);
db.failRows = true;
await expectLater(
deleteLibraryItemsByIds(db, ['retained']),
throwsStateError,
);
expect(db.rows, {'retained'});
expect(db.pathKeys, {'retained'});
});
for (final mode in ['partial', 'cancel', 'throw', 'none']) {
testWidgets('batch confirmation persists successful IDs: $mode', (
tester,
) async {
final events = <String>[];
final persisted = <List<String>>[];
final commit = Completer<void>();
int? result;
Object? failure;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () async {
try {
result = await confirmAndDeleteTracks(
context: context,
ids: ['first', 'failed', 'last'],
deleteItem: (id) async {
events.add(id);
if (mode == 'throw' && id == 'last') {
throw StateError('file delete failed');
}
return mode != 'none' && id != 'failed';
},
persistDeletedItems: (ids) async {
persisted.add(List.of(ids));
await commit.future;
events.add('committed');
},
onExitSelectionMode: () => events.add('exit'),
);
} catch (error) {
failure = error;
}
},
child: const Text('Start'),
),
),
),
),
);
await tester.tap(find.text('Start'));
await tester.pumpAndSettle();
if (mode == 'cancel') {
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
} else {
await tester.tap(find.byType(FilledButton));
}
await tester.pumpAndSettle();
if (mode == 'cancel' || mode == 'none') {
expect(persisted, isEmpty);
} else {
expect(persisted, [
mode == 'throw' ? ['first'] : ['first', 'last'],
]);
expect(events, isNot(contains('exit')));
}
commit.complete();
await tester.pumpAndSettle();
if (mode == 'throw') {
expect(failure, isStateError);
expect(events.last, 'committed');
expect(events, isNot(contains('exit')));
} else {
expect(failure, isNull);
expect(result, mode == 'cancel' ? null : (mode == 'none' ? 0 : 2));
if (mode == 'cancel') {
expect(events, isEmpty);
} else {
expect(events.last, 'exit');
}
}
expect(tester.takeException(), isNull);
});
}
}