mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
perf(collections): delete selected playlists in one transaction
This commit is contained in:
1 parent
3da4126762
commit
e0bdcb5134
8 files changed
+615
-6
No files matched your search
@@ -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 = <String, dynamic>{
|
||||
'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 = <String, List<Map<String, int>>>{
|
||||
'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<int>(() 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<DatabaseException>()),
|
||||
);
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -463,6 +463,26 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
});
|
||||
|
||||
Future<int> deletePlaylists(Iterable<String> 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<bool> addTrackToPlaylist(String playlistId, Track track) => _mutate(
|
||||
() async {
|
||||
var playlist = state.playlistById(playlistId);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -782,6 +782,33 @@ class LibraryCollectionsDatabase {
|
||||
await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]);
|
||||
}
|
||||
|
||||
Future<void> deletePlaylists(List<String> 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<void> deleteDatabasePlaylists(
|
||||
Database db,
|
||||
List<String> 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<void> upsertPlaylistTrack({
|
||||
required String playlistId,
|
||||
required String trackKey,
|
||||
|
||||
@@ -171,6 +171,15 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
|
||||
Future<void> deletePlaylist(String playlistId) =>
|
||||
_write('delete:playlist:$playlistId', () => playlists.remove(playlistId));
|
||||
|
||||
@override
|
||||
Future<void> deletePlaylists(List<String> ids) =>
|
||||
_write('bulk-playlists:remove', () {
|
||||
for (final id in ids) {
|
||||
playlists.remove(id);
|
||||
playlistTracks.remove(id);
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> 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<void>.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});
|
||||
|
||||
@@ -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<String>) _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<int> deletePlaylists(Iterable<String> 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 = <List<String>>[];
|
||||
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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void> deletePlaylist(String id) async {
|
||||
await owner.delete('playlists', where: 'id=?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePlaylists(List<String> 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<void> 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<LibraryCollectionsState> 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<Map<String, List<Map<String, Object?>>>> playlistDeletionSnapshot(
|
||||
Database db,
|
||||
) async => {
|
||||
for (final table in playlistDeletionTables)
|
||||
table: await db.rawQuery('SELECT rowid,* FROM $table ORDER BY rowid'),
|
||||
};
|
||||
Reference in new issue
Block a user