diff --git a/integration_test/playlist_deletion_batch_test.dart b/integration_test/playlist_deletion_batch_test.dart new file mode 100644 index 00000000..aa5d27cc --- /dev/null +++ b/integration_test/playlist_deletion_batch_test.dart @@ -0,0 +1,156 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; + +import '../test/support/library_collections_benchmark.dart'; +import '../test/support/playlist_deletion_fixture.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final report = { + 'platform': Platform.operatingSystem, + 'note': + 'Five alternating pairs of actual notifier calls on private platform SQLite with FK cascades. Playlists retain lazy track membership. Timings include deletion and state publication, not seeding or assertions; heartbeat is not FPS.', + }; + var completed = 0; + late Directory root; + late Database db; + setUp(() async { + root = await Directory.systemTemp.createTemp('playlist-deletion-'); + db = await openDatabase('${root.path}/collections.db'); + await createPlaylistDeletionSchema(db); + }); + tearDown(() async { + await db.close(); + await root.delete(recursive: true); + }); + tearDownAll(() { + report['completed_cases'] = completed; + binding.reportData ??= {}; + binding.reportData!['playlist_deletion'] = report; + }); + + ProviderContainer scope(LibraryCollectionsState state) => ProviderContainer( + overrides: [ + libraryCollectionsProvider.overrideWith( + () => PlaylistDeletionCollections(state, db), + ), + ], + ); + + for (final count in [20, 200]) { + testWidgets('delete $count playlists with 20 shared tracks each', ( + tester, + ) async { + final samples = >>{ + 'single': [], + 'batch': [], + }; + final ids = [for (var i = 0; i < count; i++) 'playlist-$i']; + for (var pair = 0; pair < 5; pair++) { + for (final bulk in pair.isEven ? [false, true] : [true, false]) { + final initial = await seedPlaylistDeletion(db, count); + final before = await playlistDeletionSnapshot(db); + final container = scope(initial); + try { + final notifier = container.read( + libraryCollectionsProvider.notifier, + ); + var publications = 0; + container.listen( + libraryCollectionsProvider, + (_, _) => publications++, + ); + final result = await measureCollectionOperation(() async { + if (bulk) return notifier.deletePlaylists(ids); + for (final id in ids) { + await notifier.deletePlaylist(id); + } + return count; + }); + expect(result.value, count); + expect(publications, bulk ? 1 : count); + final state = container.read(libraryCollectionsProvider); + expect(state.playlists.single, same(initial.playlists.last)); + expect(state.playlists.single.tracksLoaded, false); + expect(state.playlists.single.trackCount, 20); + expect(state.loved, initial.loved); + expect(state.wishlist, initial.wishlist); + final after = await playlistDeletionSnapshot(db); + expect( + after['playlists'], + before['playlists']!.where((p) => p['id'] == "keep'quoted"), + ); + expect( + after['playlist_tracks'], + before['playlist_tracks']!.where( + (p) => p['playlist_id'] == "keep'quoted", + ), + ); + for (final table in ['wishlist_tracks', 'loved_tracks']) { + expect(after[table], before[table]); + } + expect(await db.rawQuery('PRAGMA foreign_key_check'), isEmpty); + samples[bulk ? 'batch' : 'single']!.add({ + ...result.timing, + 'publications': publications, + }); + } finally { + container.dispose(); + } + } + } + report['playlists_$count'] = samples; + completed++; + }); + } + + for (final cascade in [false, true]) { + testWidgets( + 'failed ${cascade ? 'child' : 'parent'} deletion rolls back all chunks', + (tester) async { + final initial = await seedPlaylistDeletion( + db, + 300, + tracksPerPlaylist: 2, + ); + final before = await playlistDeletionSnapshot(db); + final container = scope(initial); + try { + final notifier = container.read(libraryCollectionsProvider.notifier); + var publications = 0; + container.listen( + libraryCollectionsProvider, + (_, _) => publications++, + ); + final table = cascade ? 'playlist_tracks' : 'playlists'; + final column = cascade ? 'playlist_id' : 'id'; + await db.execute( + "CREATE TRIGGER reject_delete BEFORE DELETE ON $table WHEN old.$column='playlist-150' BEGIN SELECT RAISE(ABORT,'denied'); END", + ); + final ids = [for (var i = 0; i < 300; i++) 'playlist-$i']; + await expectLater( + notifier.deletePlaylists(ids), + throwsA(isA()), + ); + expect(container.read(libraryCollectionsProvider), same(initial)); + expect(publications, 0); + expect(await playlistDeletionSnapshot(db), before); + await db.execute('DROP TRIGGER reject_delete'); + expect(await notifier.deletePlaylists(ids), 300); + expect(publications, 1); + expect(await db.query('playlists'), hasLength(1)); + expect(await db.query('playlist_tracks'), hasLength(2)); + expect(await db.rawQuery('PRAGMA foreign_key_check'), isEmpty); + completed++; + } finally { + container.dispose(); + } + }, + ); + } +} diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index 27631f43..140bc99f 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -463,6 +463,26 @@ class LibraryCollectionsNotifier extends Notifier { _invalidatePlaylistPickerSummaries(); }); + Future deletePlaylists(Iterable playlistIds) { + final selected = playlistIds.toSet(); + return _mutate(() async { + final ids = selected + .where((id) => state.playlistById(id) != null) + .toSet(); + if (ids.isEmpty) return 0; + await _db.deletePlaylists(ids.toList(growable: false)); + if (ref.mounted) { + state = state.copyWith( + playlists: state.playlists + .where((playlist) => !ids.contains(playlist.id)) + .toList(growable: false), + ); + _invalidatePlaylistPickerSummaries(); + } + return ids.length; + }); + } + Future addTrackToPlaylist(String playlistId, Track track) => _mutate( () async { var playlist = state.playlistById(playlistId); diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 1d2102d7..687b08ee 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -328,9 +328,7 @@ extension _QueueTabSelectionActions on _QueueTabState { if (confirmed != true || !context.mounted) return; final notifier = ref.read(libraryCollectionsProvider.notifier); - for (final id in _selectedPlaylistIds.toList()) { - await notifier.deletePlaylist(id); - } + await notifier.deletePlaylists(_selectedPlaylistIds); if (!context.mounted) return; _exitPlaylistSelectionMode(); diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index 6332c5d0..0886de6f 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -782,6 +782,33 @@ class LibraryCollectionsDatabase { await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]); } + Future deletePlaylists(List playlistIds) async { + if (playlistIds.isEmpty) return; + await deleteDatabasePlaylists(await database, playlistIds); + } + + /// Delete selected parents together; the owner's foreign-key cascade removes + /// their track memberships without loading track metadata into Dart. + static Future deleteDatabasePlaylists( + Database db, + List playlistIds, + ) async { + if (playlistIds.isEmpty) return; + await sqlite.transactionWithBusyRetry(db, (txn) async { + for (var start = 0; start < playlistIds.length; start += 128) { + final end = start + 128 < playlistIds.length + ? start + 128 + : playlistIds.length; + final ids = playlistIds.sublist(start, end); + await txn.delete( + _tablePlaylists, + where: 'id IN (${List.filled(ids.length, '?').join(',')})', + whereArgs: ids, + ); + } + }); + } + Future upsertPlaylistTrack({ required String playlistId, required String trackKey, diff --git a/test/library_collections_mutation_test.dart b/test/library_collections_mutation_test.dart index fc3f4bf3..fa0a5c8e 100644 --- a/test/library_collections_mutation_test.dart +++ b/test/library_collections_mutation_test.dart @@ -171,6 +171,15 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase { Future deletePlaylist(String playlistId) => _write('delete:playlist:$playlistId', () => playlists.remove(playlistId)); + @override + Future deletePlaylists(List ids) => + _write('bulk-playlists:remove', () { + for (final id in ids) { + playlists.remove(id); + playlistTracks.remove(id); + } + }); + @override Future upsertPlaylistTracksBatch({ required String playlistId, @@ -240,6 +249,96 @@ ProviderContainer _container(_CollectionsDatabase db) { } void main() { + for (final failFirst in [false, true]) { + test( + 'playlist bulk deletion snapshots IDs and serializes retries (fail: $failFirst)', + () async { + final db = _CollectionsDatabase()..failNextWrite = failFirst; + for (final id in ['a', 'b', 'keep']) { + db.playlists[id] = { + 'id': id, + 'name': 'Playlist $id', + 'cover_image_path': 'cover-$id', + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-02T00:00:00Z', + }; + db.playlistTracks[id] = [ + { + 'track_key': 'example:shared', + 'track_json': jsonEncode(_track('shared').toJson()), + 'added_at': '2026-01-01T00:00:00Z', + }, + ]; + } + final container = _container(db); + final notifier = container.read(libraryCollectionsProvider.notifier); + final ids = ['a', 'b', 'missing', 'a']; + final first = notifier.deletePlaylists(ids); + ids + ..clear() + ..add('keep'); + final failure = failFirst ? expectLater(first, throwsStateError) : null; + await db.writeStarted.future; + final before = container.read(libraryCollectionsProvider); + expect(before.playlists.every((p) => !p.tracksLoaded), true); + var publications = 0; + container.listen(libraryCollectionsProvider, (_, _) => publications++); + final second = notifier.deletePlaylists(['a', 'b']); + expect(db.events, ['bulk-playlists:remove']); + expect(container.read(libraryCollectionsProvider), same(before)); + db.releaseWrite.complete(); + if (failure != null) { + await failure; + } else { + expect(await first, 2); + } + expect(await second, failFirst ? 2 : 0); + final after = container.read(libraryCollectionsProvider); + expect(after.playlists.single, same(before.playlistById('keep'))); + expect(after.playlists.single.coverImagePath, 'cover-keep'); + expect(after.playlists.single.trackKeys, {'example:shared'}); + expect(db.playlists.keys, ['keep']); + expect(db.playlistTracks.keys, ['keep']); + expect(db.playlistReads, 0); + expect(publications, 1); + expect(await notifier.deletePlaylists([]), 0); + expect(await notifier.deletePlaylists(['missing', 'a']), 0); + expect(db.events.length, failFirst ? 2 : 1); + expect(publications, 1); + }, + ); + } + + test('late hydration cannot resurrect a bulk-deleted playlist', () async { + final db = _CollectionsDatabase(); + db.playlists['p'] = { + 'id': 'p', + 'name': 'Playlist', + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-01T00:00:00Z', + }; + final rows = [ + { + 'track_key': 'example:shared', + 'track_json': jsonEncode(_track('shared').toJson()), + 'added_at': '2026-01-01T00:00:00Z', + }, + ]; + db.playlistTracks['p'] = rows; + db.stagedPlaylistRows = LibraryPlaylistTracksSnapshot(rows: rows); + final container = _container(db); + final notifier = container.read(libraryCollectionsProvider.notifier); + final load = notifier.ensurePlaylistLoaded('p'); + await db.playlistReadStarted.future; + db.releaseWrite.complete(); + expect(await notifier.deletePlaylists(['p']), 1); + expect(container.read(libraryCollectionsProvider).playlists, isEmpty); + db.releasePlaylistRead.complete(); + await load; + expect(container.read(libraryCollectionsProvider).playlists, isEmpty); + expect(db.playlistReads, 1); + }); + for (final kind in ['wishlist', 'loved', 'playlist']) { for (final failFirst in [false, true]) { test( @@ -671,11 +770,11 @@ void main() { final container = _container(db); final notifier = container.read(libraryCollectionsProvider.notifier); await notifier.ensurePlaylistsLoaded(const []); - final deletion = notifier.deletePlaylist('target'); + final deletion = notifier.deletePlaylists(['target']); await db.writeStarted.future; final creation = notifier.createPlaylist('New'); await Future.delayed(Duration.zero); - expect(db.events, ['delete:playlist:target']); + expect(db.events, ['bulk-playlists:remove']); expect( container.read(libraryCollectionsProvider).playlists.map((p) => p.id), ['target', 'keep'], @@ -684,7 +783,7 @@ void main() { await deletion; final createdId = await creation; expect(db.events, [ - 'delete:playlist:target', + 'bulk-playlists:remove', 'insert:playlist:$createdId', ]); expect(db.playlists.keys.toSet(), {'keep', createdId}); diff --git a/test/local_library_lazy_loading_test.dart b/test/local_library_lazy_loading_test.dart index dcf381a1..c76e7d83 100644 --- a/test/local_library_lazy_loading_test.dart +++ b/test/local_library_lazy_loading_test.dart @@ -19,6 +19,7 @@ import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/library_search.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; class _Settings extends SettingsNotifier { @override @@ -72,6 +73,38 @@ class _Queue extends DownloadQueueNotifier { DownloadQueueState build() => DownloadQueueState(); } +class _SelectionCollections extends LibraryCollectionsNotifier { + _SelectionCollections(this._record); + final void Function(List) _record; + + @override + LibraryCollectionsState build() => LibraryCollectionsState( + isLoaded: true, + playlists: [ + for (final id in ['First', 'Second', 'Keep']) + UserPlaylistCollection( + id: id, + name: id, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + tracks: const [], + ), + ], + ); + + @override + Future deletePlaylists(Iterable ids) async { + final selected = ids.toSet(); + _record(selected.toList()); + state = state.copyWith( + playlists: state.playlists + .where((p) => !selected.contains(p.id)) + .toList(), + ); + return selected.length; + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); late _Local local; @@ -121,6 +154,69 @@ void main() { addTearDown(() => messenger.setMockMethodCallHandler(sqlite, null)); }); + for (final mornye in [false, true]) { + testWidgets('playlist selection confirms one deletion (Mornye: $mornye)', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final calls = >[]; + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + downloadHistoryProvider.overrideWith(() => history), + localLibraryProvider.overrideWith(() => local), + libraryCollectionsProvider.overrideWith( + () => _SelectionCollections(calls.add), + ), + downloadQueueProvider.overrideWith(_Queue.new), + ], + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark) + : AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: QueueTab(librarySection: 'playlists')), + ), + ), + ); + await tester.pumpAndSettle(); + final l10n = AppLocalizations.of(tester.element(find.byType(QueueTab))); + await tester.ensureVisible(find.text('First')); + await tester.longPress(find.text('First')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Second')); + await tester.tap(find.text('Second')); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.selectionDeletePlaylistsCount(2))); + await tester.pumpAndSettle(); + expect( + find.text(l10n.collectionDeletePlaylistsMessage(2)), + findsOneWidget, + ); + await tester.tap(find.text(l10n.dialogCancel)); + await tester.pumpAndSettle(); + expect(calls, isEmpty); + expect(find.text('First'), findsOneWidget); + await tester.tap(find.text(l10n.selectionDeletePlaylistsCount(2))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.dialogDelete)); + await tester.pumpAndSettle(); + expect(calls, [ + ['First', 'Second'], + ]); + expect(find.text('First'), findsNothing); + expect(find.text('Second'), findsNothing); + expect(find.text('Keep'), findsOneWidget); + expect(find.text(l10n.selectionDeletePlaylistsCount(2)), findsNothing); + expect(find.text(l10n.collectionPlaylistsDeleted(2)), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + test( 'search and browse stop observing the local index when disabled', () async { diff --git a/test/playlist_deletion_transaction_test.dart b/test/playlist_deletion_transaction_test.dart new file mode 100644 index 00000000..c2cb9aa2 --- /dev/null +++ b/test/playlist_deletion_transaction_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/library_collections_database.dart'; + +import 'support/playlist_deletion_fixture.dart'; +import 'support/sqlite_process_database.dart'; + +void main() { + for (final failure in ['none', 'parent', 'cascade']) { + test('playlist deletion cascades atomically (failure: $failure)', () async { + final db = await SqliteProcessDatabase.open(); + addTearDown(db.close); + await createPlaylistDeletionSchema(db); + await seedPlaylistDeletion(db, 300, tracksPerPlaylist: 2); + final before = await playlistDeletionSnapshot(db); + final keys = [ + for (var i = 0; i < 300; i++) 'playlist-$i', + 'missing', + 'playlist-0', + ]; + if (failure != 'none') { + final table = failure == 'parent' ? 'playlists' : 'playlist_tracks'; + final column = failure == 'parent' ? 'id' : 'playlist_id'; + await db.execute( + "CREATE TRIGGER reject_delete BEFORE DELETE ON $table WHEN old.$column='playlist-150' BEGIN SELECT RAISE(ABORT,'denied'); END", + ); + await expectLater( + LibraryCollectionsDatabase.deleteDatabasePlaylists(db, keys), + throwsStateError, + ); + expect(await playlistDeletionSnapshot(db), before); + await db.execute('DROP TRIGGER reject_delete'); + } + await LibraryCollectionsDatabase.deleteDatabasePlaylists(db, keys); + final after = await playlistDeletionSnapshot(db); + expect( + after['playlists'], + before['playlists']!.where((p) => p['id'] == "keep'quoted"), + ); + expect( + after['playlist_tracks'], + before['playlist_tracks']!.where( + (p) => p['playlist_id'] == "keep'quoted", + ), + ); + for (final table in ['wishlist_tracks', 'loved_tracks']) { + expect(after[table], before[table]); + } + expect(await db.rawQuery('PRAGMA foreign_key_check'), isEmpty); + await LibraryCollectionsDatabase.deleteDatabasePlaylists(db, keys); + await LibraryCollectionsDatabase.deleteDatabasePlaylists(db, []); + expect(await playlistDeletionSnapshot(db), after); + await LibraryCollectionsDatabase.deleteDatabasePlaylists(db, [ + "keep'quoted", + ]); + expect(await db.query('playlists'), isEmpty); + expect(await db.query('playlist_tracks'), isEmpty); + expect(await db.query('loved_tracks'), hasLength(2)); + }); + } +} diff --git a/test/support/playlist_deletion_fixture.dart b/test/support/playlist_deletion_fixture.dart new file mode 100644 index 00000000..4b7d3dd4 --- /dev/null +++ b/test/support/playlist_deletion_fixture.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; + +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/services/library_collections_database.dart'; + +class PlaylistDeletionDatabase implements LibraryCollectionsDatabase { + PlaylistDeletionDatabase(this.owner); + final Database owner; + + @override + Future deletePlaylist(String id) async { + await owner.delete('playlists', where: 'id=?', whereArgs: [id]); + } + + @override + Future deletePlaylists(List ids) => + LibraryCollectionsDatabase.deleteDatabasePlaylists(owner, ids); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class PlaylistDeletionCollections extends LibraryCollectionsNotifier { + PlaylistDeletionCollections(this._initial, Database db) + : super(database: PlaylistDeletionDatabase(db)); + final LibraryCollectionsState _initial; + + @override + LibraryCollectionsState build() => _initial; +} + +const playlistDeletionTables = [ + 'playlists', + 'playlist_tracks', + 'wishlist_tracks', + 'loved_tracks', +]; + +Future createPlaylistDeletionSchema(Database db) async { + await db.execute('PRAGMA foreign_keys=ON'); + await db.execute('PRAGMA recursive_triggers=ON'); + await db.rawQuery('PRAGMA journal_mode=WAL'); + await db.execute('PRAGMA synchronous=NORMAL'); + await db.rawQuery('PRAGMA busy_timeout=5000'); + await db.execute('''CREATE TABLE playlists( + id TEXT PRIMARY KEY, name TEXT NOT NULL, cover_image_path TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'''); + await db.execute('''CREATE TABLE playlist_tracks( + playlist_id TEXT NOT NULL, track_key TEXT NOT NULL, track_json TEXT NOT NULL, + added_at TEXT NOT NULL, PRIMARY KEY(playlist_id, track_key), + FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE)'''); + await db.execute( + 'CREATE INDEX idx_playlists_created_at ON playlists(created_at DESC)', + ); + await db.execute( + 'CREATE INDEX idx_playlist_tracks_playlist_id ON playlist_tracks(playlist_id)', + ); + for (final table in ['wishlist_tracks', 'loved_tracks']) { + await db.execute('''CREATE TABLE $table(track_key TEXT PRIMARY KEY, + track_json TEXT NOT NULL, added_at TEXT NOT NULL)'''); + await db.execute( + 'CREATE INDEX idx_${table}_added_at ON $table(added_at DESC)', + ); + } +} + +Future seedPlaylistDeletion( + Database db, + int count, { + int tracksPerPlaylist = 20, +}) async { + final now = DateTime.utc(2026); + final stamp = now.toIso8601String(); + final entries = [ + for (var i = 0; i < tracksPerPlaylist; i++) + CollectionTrackEntry( + key: 'example:$i', + addedAt: now, + track: Track( + id: '$i', + name: 'Track $i — 音楽', + artistName: 'Artist', + albumName: 'Album', + source: 'example', + duration: 180, + ), + ), + ]; + final playlists = [ + for (final id in [ + for (var i = 0; i < count; i++) 'playlist-$i', + "keep'quoted", + ]) + UserPlaylistCollection( + id: id, + name: 'Name $id', + createdAt: now, + updatedAt: now, + coverImagePath: 'cover-$id', + tracks: const [], + trackKeys: entries.map((entry) => entry.key).toSet(), + tracksLoaded: false, + ), + ]; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final table in playlistDeletionTables) { + batch.delete(table); + } + for (final playlist in playlists) { + batch.insert('playlists', { + 'id': playlist.id, + 'name': playlist.name, + 'cover_image_path': playlist.coverImagePath, + 'created_at': stamp, + 'updated_at': stamp, + }); + for (final entry in entries) { + batch.insert('playlist_tracks', { + 'playlist_id': playlist.id, + 'track_key': entry.key, + 'track_json': jsonEncode(entry.track.toJson()), + 'added_at': stamp, + }); + } + } + for (final table in ['wishlist_tracks', 'loved_tracks']) { + for (final entry in entries) { + batch.insert(table, { + 'track_key': entry.key, + 'track_json': jsonEncode(entry.track.toJson()), + 'added_at': stamp, + }); + } + } + await batch.commit(noResult: true); + }); + return LibraryCollectionsState( + isLoaded: true, + playlists: playlists, + wishlist: entries, + loved: entries, + ); +} + +Future>>> playlistDeletionSnapshot( + Database db, +) async => { + for (final table in playlistDeletionTables) + table: await db.rawQuery('SELECT rowid,* FROM $table ORDER BY rowid'), +};