mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
perf(library): batch album deletion and refresh once
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user