mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
perf(collections): remove selected tracks in one transaction
This commit is contained in:
1 parent
105f1b0946
commit
8ffd232b35
7 files changed
+882
-24
No files matched your search
@@ -0,0 +1,346 @@
|
||||
import 'dart:convert';
|
||||
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 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
|
||||
import '../test/support/library_collections_benchmark.dart';
|
||||
import '../test/support/playlist_batch_benchmark.dart';
|
||||
|
||||
class _Database implements LibraryCollectionsDatabase {
|
||||
_Database(this.owner);
|
||||
final Database owner;
|
||||
|
||||
@override
|
||||
Future<void> deleteWishlistEntry(String key) async {
|
||||
await owner.delete(
|
||||
'wishlist_tracks',
|
||||
where: 'track_key=?',
|
||||
whereArgs: [key],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteLovedEntry(String key) async {
|
||||
await owner.delete('loved_tracks', where: 'track_key=?', whereArgs: [key]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePlaylistTrack({
|
||||
required String playlistId,
|
||||
required String trackKey,
|
||||
required String playlistUpdatedAt,
|
||||
}) => owner.transaction((txn) async {
|
||||
await txn.delete(
|
||||
'playlist_tracks',
|
||||
where: 'playlist_id=? AND track_key=?',
|
||||
whereArgs: [playlistId, trackKey],
|
||||
);
|
||||
await txn.update(
|
||||
'playlists',
|
||||
{'updated_at': playlistUpdatedAt},
|
||||
where: 'id=?',
|
||||
whereArgs: [playlistId],
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> deleteWishlistTracks(List<String> keys) =>
|
||||
LibraryCollectionsDatabase.deleteDatabaseWishlistTracks(owner, keys);
|
||||
|
||||
@override
|
||||
Future<void> deleteLovedTracks(List<String> keys) =>
|
||||
LibraryCollectionsDatabase.deleteDatabaseLovedTracks(owner, keys);
|
||||
|
||||
@override
|
||||
Future<void> deletePlaylistTracks({
|
||||
required String playlistId,
|
||||
required List<String> trackKeys,
|
||||
required String playlistUpdatedAt,
|
||||
}) => LibraryCollectionsDatabase.deleteDatabasePlaylistTracks(
|
||||
owner,
|
||||
playlistId: playlistId,
|
||||
trackKeys: trackKeys,
|
||||
playlistUpdatedAt: playlistUpdatedAt,
|
||||
);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Collections extends LibraryCollectionsNotifier {
|
||||
_Collections(this._initial, {required super.database});
|
||||
final LibraryCollectionsState _initial;
|
||||
@override
|
||||
LibraryCollectionsState build() => _initial;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final report = <String, dynamic>{
|
||||
'platform': Platform.operatingSystem,
|
||||
'note':
|
||||
'Five alternating pairs, real production notifier and private platform SQLite. Heartbeat is not FPS.',
|
||||
};
|
||||
var completed = 0;
|
||||
late Directory root;
|
||||
late Database db;
|
||||
final now = DateTime.utc(2026);
|
||||
final stamp = now.toIso8601String();
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('collection-remove-device-');
|
||||
db = await openDatabase(
|
||||
'${root.path}/collections.db',
|
||||
version: 2,
|
||||
onConfigure: (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');
|
||||
},
|
||||
onCreate: (db, _) async {
|
||||
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)',
|
||||
);
|
||||
}
|
||||
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_playlist_tracks_playlist_id ON playlist_tracks(playlist_id)',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
await root.delete(recursive: true);
|
||||
});
|
||||
tearDownAll(() {
|
||||
report['completed_cases'] = completed;
|
||||
binding.reportData ??= {};
|
||||
binding.reportData!['collection_removal'] = report;
|
||||
});
|
||||
|
||||
Future<LibraryCollectionsState> seed(int count) async {
|
||||
final tracks = await playlistBatchFixture(count + 1);
|
||||
final prepared = inlinePlaylistBatch(tracks, {}, now);
|
||||
await db.transaction((txn) async {
|
||||
final batch = txn.batch();
|
||||
for (final table in [
|
||||
'playlist_tracks',
|
||||
'playlists',
|
||||
'wishlist_tracks',
|
||||
'loved_tracks',
|
||||
]) {
|
||||
batch.delete(table);
|
||||
}
|
||||
for (final id in ['p', 'other']) {
|
||||
batch.insert('playlists', {
|
||||
'id': id,
|
||||
'name': id,
|
||||
'cover_image_path': 'cover',
|
||||
'created_at': stamp,
|
||||
'updated_at': stamp,
|
||||
});
|
||||
}
|
||||
for (final row in prepared.rows) {
|
||||
batch.insert('wishlist_tracks', row);
|
||||
batch.insert('loved_tracks', row);
|
||||
batch.insert('playlist_tracks', {'playlist_id': 'p', ...row});
|
||||
}
|
||||
for (final row in prepared.rows.take(2)) {
|
||||
batch.insert('playlist_tracks', {'playlist_id': 'other', ...row});
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
return LibraryCollectionsState(
|
||||
isLoaded: true,
|
||||
wishlist: prepared.entries.reversed.toList(),
|
||||
loved: prepared.entries.reversed.toList(),
|
||||
playlists: [
|
||||
for (final id in ['p', 'other'])
|
||||
UserPlaylistCollection(
|
||||
id: id,
|
||||
name: id,
|
||||
coverImagePath: 'cover',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
tracks: id == 'p'
|
||||
? prepared.entries
|
||||
: prepared.entries.take(2).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
ProviderContainer container(LibraryCollectionsState state) =>
|
||||
ProviderContainer(
|
||||
overrides: [
|
||||
libraryCollectionsProvider.overrideWith(
|
||||
() => _Collections(state, database: _Database(db)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<int> remove(
|
||||
LibraryCollectionsNotifier notifier,
|
||||
String kind,
|
||||
List<String> keys,
|
||||
) => switch (kind) {
|
||||
'wishlist' => notifier.removeWishlistTracks(keys),
|
||||
'loved' => notifier.removeLovedTracks(keys),
|
||||
_ => notifier.removeTracksFromPlaylist('p', keys),
|
||||
};
|
||||
|
||||
for (final kind in ['wishlist', 'loved', 'playlist']) {
|
||||
testWidgets('remove 2000 $kind tracks in one selection', (tester) async {
|
||||
final samples = <String, List<Map<String, int>>>{
|
||||
'per_track': [],
|
||||
'batch': [],
|
||||
};
|
||||
for (var pair = 0; pair < 5; pair++) {
|
||||
for (final candidate in pair.isEven ? [false, true] : [true, false]) {
|
||||
final initial = await seed(2000);
|
||||
final owner = container(initial);
|
||||
try {
|
||||
final notifier = owner.read(libraryCollectionsProvider.notifier);
|
||||
final keys = [
|
||||
for (var index = 0; index < 2000; index++) 'example:$index',
|
||||
];
|
||||
var publications = 0;
|
||||
owner.listen(libraryCollectionsProvider, (_, _) => publications++);
|
||||
final measured = await measureCollectionOperation(() async {
|
||||
if (candidate) {
|
||||
expect(await remove(notifier, kind, keys), 2000);
|
||||
} else {
|
||||
for (final key in keys) {
|
||||
switch (kind) {
|
||||
case 'wishlist':
|
||||
await notifier.removeFromWishlist(key);
|
||||
case 'loved':
|
||||
await notifier.removeFromLoved(key);
|
||||
case 'playlist':
|
||||
await notifier.removeTrackFromPlaylist('p', key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(publications, candidate ? 1 : 2000);
|
||||
final state = owner.read(libraryCollectionsProvider);
|
||||
final remaining = switch (kind) {
|
||||
'wishlist' => state.wishlist,
|
||||
'loved' => state.loved,
|
||||
_ => state.playlistById('p')!.tracks,
|
||||
};
|
||||
expect(remaining.single.key, 'example:2000');
|
||||
expect(remaining.single.addedAt, now);
|
||||
expect(
|
||||
state.playlistById('other'),
|
||||
same(initial.playlistById('other')),
|
||||
);
|
||||
for (final table in ['wishlist_tracks', 'loved_tracks']) {
|
||||
expect(
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM $table'),
|
||||
),
|
||||
table == '${kind}_tracks' ? 1 : 2001,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery(
|
||||
"SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id='other'",
|
||||
),
|
||||
),
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery(
|
||||
"SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id='p'",
|
||||
),
|
||||
),
|
||||
kind == 'playlist' ? 1 : 2001,
|
||||
);
|
||||
final rows = await db.query(
|
||||
'${kind}_tracks',
|
||||
where: kind == 'playlist' ? 'playlist_id=?' : null,
|
||||
whereArgs: kind == 'playlist' ? ['p'] : null,
|
||||
);
|
||||
expect(
|
||||
rows.single['track_json'],
|
||||
jsonEncode(remaining.single.track.toJson()),
|
||||
);
|
||||
expect(rows.single['added_at'], stamp);
|
||||
expect(
|
||||
(await db.query(
|
||||
'playlists',
|
||||
where: 'id=?',
|
||||
whereArgs: ['p'],
|
||||
)).single['updated_at'],
|
||||
state.playlistById('p')!.updatedAt.toIso8601String(),
|
||||
);
|
||||
samples[candidate ? 'batch' : 'per_track']!.add({
|
||||
...measured.timing,
|
||||
'publications': publications,
|
||||
});
|
||||
} finally {
|
||||
owner.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
report[kind] = samples;
|
||||
completed++;
|
||||
});
|
||||
|
||||
testWidgets('$kind rollback retains state and retry succeeds', (
|
||||
tester,
|
||||
) async {
|
||||
final initial = await seed(300);
|
||||
final owner = container(initial);
|
||||
try {
|
||||
final notifier = owner.read(libraryCollectionsProvider.notifier);
|
||||
var publications = 0;
|
||||
owner.listen(libraryCollectionsProvider, (_, _) => publications++);
|
||||
await db.execute(
|
||||
"CREATE TRIGGER reject_delete BEFORE DELETE ON ${kind}_tracks WHEN old.track_key='example:150' BEGIN SELECT RAISE(ABORT,'denied'); END",
|
||||
);
|
||||
final keys = [for (var i = 0; i < 300; i++) 'example:$i'];
|
||||
await expectLater(
|
||||
remove(notifier, kind, keys),
|
||||
throwsA(isA<DatabaseException>()),
|
||||
);
|
||||
expect(owner.read(libraryCollectionsProvider), same(initial));
|
||||
expect(publications, 0);
|
||||
expect(
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM ${kind}_tracks${kind == 'playlist' ? " WHERE playlist_id='p'" : ''}',
|
||||
),
|
||||
),
|
||||
301,
|
||||
);
|
||||
await db.execute('DROP TRIGGER reject_delete');
|
||||
expect(await remove(notifier, kind, keys), 300);
|
||||
expect(publications, 1);
|
||||
expect(await db.rawQuery('PRAGMA foreign_key_check'), isEmpty);
|
||||
completed++;
|
||||
} finally {
|
||||
owner.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,48 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
dbDelete: _db.deleteLovedEntry,
|
||||
);
|
||||
|
||||
Future<int> _removeTrackEntries(
|
||||
Iterable<String> trackKeys, {
|
||||
required bool Function(String key) contains,
|
||||
required List<CollectionTrackEntry> Function() select,
|
||||
required LibraryCollectionsState Function(List<CollectionTrackEntry>)
|
||||
withList,
|
||||
required Future<void> Function(List<String>) dbDelete,
|
||||
}) {
|
||||
final selected = trackKeys.toSet();
|
||||
return _mutate(() async {
|
||||
final keys = selected.where(contains).toSet();
|
||||
if (keys.isEmpty) return 0;
|
||||
await dbDelete(keys.toList(growable: false));
|
||||
if (ref.mounted) {
|
||||
state = withList(
|
||||
select()
|
||||
.where((entry) => !keys.contains(entry.key))
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
return keys.length;
|
||||
});
|
||||
}
|
||||
|
||||
Future<int> removeWishlistTracks(Iterable<String> trackKeys) =>
|
||||
_removeTrackEntries(
|
||||
trackKeys,
|
||||
contains: (key) => state.containsWishlistKey(key),
|
||||
select: () => state.wishlist,
|
||||
withList: (list) => state.copyWith(wishlist: list),
|
||||
dbDelete: _db.deleteWishlistTracks,
|
||||
);
|
||||
|
||||
Future<int> removeLovedTracks(Iterable<String> trackKeys) =>
|
||||
_removeTrackEntries(
|
||||
trackKeys,
|
||||
contains: (key) => state.containsLovedKey(key),
|
||||
select: () => state.loved,
|
||||
withList: (list) => state.copyWith(loved: list),
|
||||
dbDelete: _db.deleteLovedTracks,
|
||||
);
|
||||
|
||||
Future<String> createPlaylist(String name) => _mutate(() async {
|
||||
final now = DateTime.now();
|
||||
final id = 'pl_${now.microsecondsSinceEpoch}';
|
||||
@@ -544,6 +586,43 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
});
|
||||
|
||||
Future<int> removeTracksFromPlaylist(
|
||||
String playlistId,
|
||||
Iterable<String> trackKeys,
|
||||
) {
|
||||
final selected = trackKeys.toSet();
|
||||
return _mutate(() async {
|
||||
var playlist = state.playlistById(playlistId);
|
||||
if (playlist == null || !selected.any(playlist.containsTrackKey)) {
|
||||
return 0;
|
||||
}
|
||||
await ensurePlaylistLoaded(playlistId);
|
||||
if (!ref.mounted) return 0;
|
||||
playlist = state.playlistById(playlistId);
|
||||
if (playlist == null) return 0;
|
||||
final keys = selected.where(playlist.containsTrackKey).toSet();
|
||||
if (keys.isEmpty) return 0;
|
||||
|
||||
final now = DateTime.now();
|
||||
await _db.deletePlaylistTracks(
|
||||
playlistId: playlistId,
|
||||
trackKeys: keys.toList(growable: false),
|
||||
playlistUpdatedAt: now.toIso8601String(),
|
||||
);
|
||||
_replacePlaylistById(
|
||||
playlistId,
|
||||
(current) => current.copyWith(
|
||||
tracks: current.tracks
|
||||
.where((entry) => !keys.contains(entry.key))
|
||||
.toList(growable: false),
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
return keys.length;
|
||||
});
|
||||
}
|
||||
|
||||
Future<Directory> _playlistCoversDir() async {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
final dir = Directory(p.join(appDir.path, 'playlist_covers'));
|
||||
|
||||
@@ -165,20 +165,21 @@ class _LibraryTracksFolderScreenState
|
||||
final count = keysToRemove.length;
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
|
||||
for (final key in keysToRemove) {
|
||||
switch (widget.mode) {
|
||||
case LibraryTracksFolderMode.wishlist:
|
||||
await notifier.removeFromWishlist(key);
|
||||
break;
|
||||
case LibraryTracksFolderMode.loved:
|
||||
await notifier.removeFromLoved(key);
|
||||
break;
|
||||
case LibraryTracksFolderMode.playlist:
|
||||
if (widget.playlistId != null) {
|
||||
await notifier.removeTrackFromPlaylist(widget.playlistId!, key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
switch (widget.mode) {
|
||||
case LibraryTracksFolderMode.wishlist:
|
||||
await notifier.removeWishlistTracks(keysToRemove);
|
||||
break;
|
||||
case LibraryTracksFolderMode.loved:
|
||||
await notifier.removeLovedTracks(keysToRemove);
|
||||
break;
|
||||
case LibraryTracksFolderMode.playlist:
|
||||
if (widget.playlistId != null) {
|
||||
await notifier.removeTracksFromPlaylist(
|
||||
widget.playlistId!,
|
||||
keysToRemove,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
exitSelectionMode();
|
||||
|
||||
@@ -616,6 +616,22 @@ class LibraryCollectionsDatabase {
|
||||
Future<void> deleteWishlistEntry(String trackKey) =>
|
||||
_deleteEntry(_tableWishlist, 'track', trackKey);
|
||||
|
||||
Future<void> deleteWishlistTracks(List<String> keys) async {
|
||||
if (keys.isEmpty) return;
|
||||
await deleteDatabaseWishlistTracks(await database, keys);
|
||||
}
|
||||
|
||||
static Future<void> deleteDatabaseWishlistTracks(
|
||||
Database db,
|
||||
List<String> keys,
|
||||
) async {
|
||||
if (keys.isEmpty) return;
|
||||
await sqlite.transactionWithBusyRetry(
|
||||
db,
|
||||
(txn) => _deleteTrackKeys(txn, _tableWishlist, keys),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> upsertLovedEntry({
|
||||
required String trackKey,
|
||||
required String trackJson,
|
||||
@@ -677,16 +693,28 @@ class LibraryCollectionsDatabase {
|
||||
List<String> keys,
|
||||
) async {
|
||||
if (keys.isEmpty) return;
|
||||
await sqlite.transactionWithBusyRetry(db, (txn) async {
|
||||
for (var start = 0; start < keys.length; start += 128) {
|
||||
final chunk = keys.skip(start).take(128).toList(growable: false);
|
||||
await txn.delete(
|
||||
_tableLoved,
|
||||
where: 'track_key IN (${List.filled(chunk.length, '?').join(',')})',
|
||||
whereArgs: chunk,
|
||||
);
|
||||
}
|
||||
});
|
||||
await sqlite.transactionWithBusyRetry(
|
||||
db,
|
||||
(txn) => _deleteTrackKeys(txn, _tableLoved, keys),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _deleteTrackKeys(
|
||||
DatabaseExecutor db,
|
||||
String table,
|
||||
List<String> keys, {
|
||||
String? playlistId,
|
||||
}) async {
|
||||
for (var start = 0; start < keys.length; start += 128) {
|
||||
final end = start + 128 < keys.length ? start + 128 : keys.length;
|
||||
final chunk = keys.sublist(start, end);
|
||||
await db.delete(
|
||||
table,
|
||||
where:
|
||||
'${playlistId == null ? '' : 'playlist_id = ? AND '}track_key IN (${List.filled(chunk.length, '?').join(',')})',
|
||||
whereArgs: [?playlistId, ...chunk],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> upsertFavoriteArtistEntry({
|
||||
@@ -859,6 +887,43 @@ class LibraryCollectionsDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deletePlaylistTracks({
|
||||
required String playlistId,
|
||||
required List<String> trackKeys,
|
||||
required String playlistUpdatedAt,
|
||||
}) async {
|
||||
if (trackKeys.isEmpty) return;
|
||||
await deleteDatabasePlaylistTracks(
|
||||
await database,
|
||||
playlistId: playlistId,
|
||||
trackKeys: trackKeys,
|
||||
playlistUpdatedAt: playlistUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> deleteDatabasePlaylistTracks(
|
||||
Database db, {
|
||||
required String playlistId,
|
||||
required List<String> trackKeys,
|
||||
required String playlistUpdatedAt,
|
||||
}) async {
|
||||
if (trackKeys.isEmpty) return;
|
||||
await sqlite.transactionWithBusyRetry(db, (txn) async {
|
||||
await _deleteTrackKeys(
|
||||
txn,
|
||||
_tablePlaylistTracks,
|
||||
trackKeys,
|
||||
playlistId: playlistId,
|
||||
);
|
||||
await txn.update(
|
||||
_tablePlaylists,
|
||||
{'updated_at': playlistUpdatedAt},
|
||||
where: 'id = ?',
|
||||
whereArgs: [playlistId],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Wipes every collection table and rewrites them from a restored backup.
|
||||
///
|
||||
/// [collectionsJson] must use the same shape as
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
|
||||
import 'support/sqlite_process_database.dart';
|
||||
|
||||
void main() {
|
||||
for (final kind in ['wishlist', 'loved', 'playlist']) {
|
||||
for (final failure in [
|
||||
'none',
|
||||
'row',
|
||||
if (kind == 'playlist') 'timestamp',
|
||||
]) {
|
||||
test(
|
||||
'$kind removal preserves other rows and rolls back all chunks (failure: $failure)',
|
||||
() async {
|
||||
final root = await Directory.systemTemp.createTemp(
|
||||
'collection-removal-',
|
||||
);
|
||||
final db = await SqliteProcessDatabase.open(
|
||||
path: '${root.path}/collections.db',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await db.close();
|
||||
await root.delete(recursive: true);
|
||||
});
|
||||
final table = '${kind}_tracks';
|
||||
await db.execute(
|
||||
'CREATE TABLE playlists(id TEXT PRIMARY KEY,updated_at TEXT)',
|
||||
);
|
||||
await db.execute(
|
||||
"INSERT INTO playlists VALUES('p','old'),('other','old')",
|
||||
);
|
||||
await db.execute(
|
||||
kind == 'playlist'
|
||||
? 'CREATE TABLE $table(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))'
|
||||
: 'CREATE TABLE $table(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL)',
|
||||
);
|
||||
final batch = db.batch();
|
||||
for (final playlist in ['p', if (kind == 'playlist') 'other']) {
|
||||
for (var index = 0; index < 301; index++) {
|
||||
batch.insert(table, {
|
||||
if (kind == 'playlist') 'playlist_id': playlist,
|
||||
'track_key': '$index',
|
||||
'track_json': '{"title":"音楽 $index"}',
|
||||
'added_at': 'original-$index',
|
||||
});
|
||||
}
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
final before = await db.rawQuery(
|
||||
'SELECT rowid,* FROM $table ORDER BY rowid',
|
||||
);
|
||||
if (failure == 'row') {
|
||||
await db.execute(
|
||||
"CREATE TRIGGER reject_delete BEFORE DELETE ON $table WHEN old.track_key='150' BEGIN SELECT RAISE(ABORT,'denied'); END",
|
||||
);
|
||||
} else if (failure == 'timestamp') {
|
||||
await db.execute(
|
||||
"CREATE TRIGGER reject_delete BEFORE UPDATE ON playlists BEGIN SELECT RAISE(ABORT,'denied'); END",
|
||||
);
|
||||
}
|
||||
Future<void> remove(List<String> keys) => switch (kind) {
|
||||
'wishlist' =>
|
||||
LibraryCollectionsDatabase.deleteDatabaseWishlistTracks(db, keys),
|
||||
'loved' => LibraryCollectionsDatabase.deleteDatabaseLovedTracks(
|
||||
db,
|
||||
keys,
|
||||
),
|
||||
_ => LibraryCollectionsDatabase.deleteDatabasePlaylistTracks(
|
||||
db,
|
||||
playlistId: 'p',
|
||||
trackKeys: keys,
|
||||
playlistUpdatedAt: 'new',
|
||||
),
|
||||
};
|
||||
final operation = remove([for (var i = 0; i < 300; i++) '$i']);
|
||||
if (failure != 'none') {
|
||||
await expectLater(operation, throwsStateError);
|
||||
expect(
|
||||
await db.rawQuery('SELECT rowid,* FROM $table ORDER BY rowid'),
|
||||
before,
|
||||
);
|
||||
expect(
|
||||
await db.rawQuery('SELECT updated_at FROM playlists WHERE id=?', [
|
||||
'p',
|
||||
]),
|
||||
[
|
||||
{'updated_at': 'old'},
|
||||
],
|
||||
);
|
||||
await db.execute('DROP TRIGGER reject_delete');
|
||||
await remove([for (var i = 0; i < 300; i++) '$i']);
|
||||
} else {
|
||||
await operation;
|
||||
}
|
||||
expect(
|
||||
await db.rawQuery('SELECT rowid,* FROM $table ORDER BY rowid'),
|
||||
[
|
||||
for (final row in before)
|
||||
if (row['track_key'] == '300' ||
|
||||
(kind == 'playlist' && row['playlist_id'] == 'other'))
|
||||
row,
|
||||
],
|
||||
);
|
||||
final playlists = await db.rawQuery(
|
||||
'SELECT * FROM playlists ORDER BY rowid',
|
||||
);
|
||||
expect(playlists, [
|
||||
{'id': 'p', 'updated_at': kind == 'playlist' ? 'new' : 'old'},
|
||||
{'id': 'other', 'updated_at': 'old'},
|
||||
]);
|
||||
await remove(const []);
|
||||
expect(
|
||||
await db.rawQuery('SELECT * FROM playlists ORDER BY rowid'),
|
||||
playlists,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,14 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
|
||||
Future<void> deleteWishlistEntry(String trackKey) =>
|
||||
_write('delete:wishlist:$trackKey', () => wishlist.remove(trackKey));
|
||||
|
||||
@override
|
||||
Future<void> deleteWishlistTracks(List<String> keys) =>
|
||||
_write('bulk-wishlist:remove', () {
|
||||
for (final key in keys) {
|
||||
wishlist.remove(key);
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> upsertLovedEntry({
|
||||
required String trackKey,
|
||||
@@ -193,6 +201,19 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> deletePlaylistTracks({
|
||||
required String playlistId,
|
||||
required List<String> trackKeys,
|
||||
required String playlistUpdatedAt,
|
||||
}) => _write('bulk-playlist:remove', () {
|
||||
final keys = trackKeys.toSet();
|
||||
playlistTracks[playlistId]!.removeWhere(
|
||||
(row) => keys.contains(row['track_key']),
|
||||
);
|
||||
playlists[playlistId]!['updated_at'] = playlistUpdatedAt;
|
||||
});
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -219,6 +240,117 @@ ProviderContainer _container(_CollectionsDatabase db) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final kind in ['wishlist', 'loved', 'playlist']) {
|
||||
for (final failFirst in [false, true]) {
|
||||
test(
|
||||
'bulk $kind removal is atomic and serializes repeated clicks (fail: $failFirst)',
|
||||
() async {
|
||||
final db = _CollectionsDatabase()..failNextWrite = failFirst;
|
||||
final tracks = [
|
||||
for (final id in ['a', 'b', 'keep']) _track(id),
|
||||
];
|
||||
final rows = [
|
||||
for (final track in tracks)
|
||||
<String, dynamic>{
|
||||
'track_key': trackCollectionKey(track),
|
||||
'track_json': jsonEncode(track.toJson()),
|
||||
'added_at': '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
for (final row in rows) {
|
||||
db.wishlist[row['track_key'] as String] = {...row};
|
||||
db.loved[row['track_key'] as String] = {...row};
|
||||
}
|
||||
for (final id in ['p', 'other']) {
|
||||
db.playlists[id] = {
|
||||
'id': id,
|
||||
'name': id,
|
||||
'cover_image_path': 'cover',
|
||||
'created_at': '2026-01-01T00:00:00Z',
|
||||
'updated_at': '2026-01-01T00:00:00Z',
|
||||
};
|
||||
db.playlistTracks[id] = [
|
||||
for (final row in rows) {...row},
|
||||
];
|
||||
}
|
||||
db.releasePlaylistRead.complete();
|
||||
final container = _container(db);
|
||||
final notifier = container.read(libraryCollectionsProvider.notifier);
|
||||
Future<int> remove(Iterable<String> keys) => switch (kind) {
|
||||
'wishlist' => notifier.removeWishlistTracks(keys),
|
||||
'loved' => notifier.removeLovedTracks(keys),
|
||||
_ => notifier.removeTracksFromPlaylist('p', keys),
|
||||
};
|
||||
final selected = ['example:a', 'example:b', 'example:a', 'missing'];
|
||||
final first = remove(selected);
|
||||
selected
|
||||
..clear()
|
||||
..add('example:keep');
|
||||
final failure = failFirst
|
||||
? expectLater(first, throwsStateError)
|
||||
: null;
|
||||
await db.writeStarted.future;
|
||||
final before = container.read(libraryCollectionsProvider);
|
||||
var publications = 0;
|
||||
container.listen(
|
||||
libraryCollectionsProvider,
|
||||
(_, _) => publications++,
|
||||
);
|
||||
final second = remove(['example:a', 'example:b']);
|
||||
expect(db.events, ['bulk-$kind: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);
|
||||
expect(publications, 1);
|
||||
final after = container.read(libraryCollectionsProvider);
|
||||
final remaining = switch (kind) {
|
||||
'wishlist' => after.wishlist,
|
||||
'loved' => after.loved,
|
||||
_ => after.playlistById('p')!.tracks,
|
||||
};
|
||||
expect(remaining.map((entry) => entry.track.id), ['keep']);
|
||||
expect(remaining.single.addedAt, DateTime.utc(2026));
|
||||
expect(
|
||||
after.playlistById('other'),
|
||||
same(before.playlistById('other')),
|
||||
);
|
||||
expect(db.playlistTracks['other'], rows);
|
||||
expect(db.wishlist.length, kind == 'wishlist' ? 1 : 3);
|
||||
expect(db.loved.length, kind == 'loved' ? 1 : 3);
|
||||
expect(db.playlistTracks['p']!.length, kind == 'playlist' ? 1 : 3);
|
||||
expect(db.events.length, failFirst ? 2 : 1);
|
||||
expect(db.playlistReads, kind == 'playlist' ? 1 : 0);
|
||||
if (kind == 'playlist') {
|
||||
final playlist = after.playlistById('p')!;
|
||||
expect(playlist.coverImagePath, 'cover');
|
||||
expect(playlist.createdAt, DateTime.utc(2026));
|
||||
expect(playlist.trackKeys, {'example:keep'});
|
||||
expect(playlist.updatedAt.isAfter(DateTime.utc(2026)), isTrue);
|
||||
expect(
|
||||
db.playlists['p']!['updated_at'],
|
||||
playlist.updatedAt.toIso8601String(),
|
||||
);
|
||||
}
|
||||
expect(await remove(const []), 0);
|
||||
expect(await remove(['missing']), 0);
|
||||
expect(
|
||||
await notifier.removeTracksFromPlaylist('missing', [
|
||||
'example:keep',
|
||||
]),
|
||||
0,
|
||||
);
|
||||
expect(container.read(libraryCollectionsProvider), same(after));
|
||||
expect(publications, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final failFirst in [false, true]) {
|
||||
test(
|
||||
'Love All is atomic and serializes repeated clicks (failure: $failFirst)',
|
||||
|
||||
@@ -97,6 +97,49 @@ class _Collections extends LibraryCollectionsNotifier {
|
||||
Future<void> ensurePlaylistLoaded(String playlistId) async {}
|
||||
}
|
||||
|
||||
class _RemovalCollections extends _Collections {
|
||||
_RemovalCollections(this._record);
|
||||
final void Function(String, List<String>) _record;
|
||||
|
||||
@override
|
||||
LibraryCollectionsState build() {
|
||||
final initial = super.build();
|
||||
final tracks = initial.playlists.single.tracks;
|
||||
return initial.copyWith(wishlist: tracks, loved: tracks);
|
||||
}
|
||||
|
||||
Future<int> _remove(String kind, Iterable<String> keys) async {
|
||||
final selected = keys.toSet();
|
||||
_record(kind, selected.toList());
|
||||
List<CollectionTrackEntry> keep(List<CollectionTrackEntry> entries) =>
|
||||
entries.where((entry) => !selected.contains(entry.key)).toList();
|
||||
state = switch (kind) {
|
||||
'wishlist' => state.copyWith(wishlist: keep(state.wishlist)),
|
||||
'loved' => state.copyWith(loved: keep(state.loved)),
|
||||
_ => state.copyWith(
|
||||
playlists: [
|
||||
state.playlists.single.copyWith(
|
||||
tracks: keep(state.playlists.single.tracks),
|
||||
),
|
||||
],
|
||||
),
|
||||
};
|
||||
return selected.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> removeWishlistTracks(Iterable<String> keys) =>
|
||||
_remove('wishlist', keys);
|
||||
|
||||
@override
|
||||
Future<int> removeLovedTracks(Iterable<String> keys) =>
|
||||
_remove('loved', keys);
|
||||
|
||||
@override
|
||||
Future<int> removeTracksFromPlaylist(String id, Iterable<String> keys) =>
|
||||
_remove('playlist', keys);
|
||||
}
|
||||
|
||||
class _Library extends LocalLibraryNotifier {
|
||||
@override
|
||||
LocalLibraryState build() => LocalLibraryState();
|
||||
@@ -111,6 +154,75 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
for (final mode in LibraryTracksFolderMode.values) {
|
||||
for (final mornye in [false, true]) {
|
||||
testWidgets(
|
||||
'selection dispatches one ${mode.name} removal (Mornye: $mornye)',
|
||||
(tester) async {
|
||||
final calls = <(String, List<String>)>[];
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
musicPlayerControllerProvider.overrideWithValue(_Player()),
|
||||
playbackProvider.overrideWith(_Playback.new),
|
||||
libraryCollectionsProvider.overrideWith(
|
||||
() => _RemovalCollections(
|
||||
(kind, keys) => calls.add((kind, keys)),
|
||||
),
|
||||
),
|
||||
localLibraryProvider.overrideWith(_Library.new),
|
||||
downloadHistoryProvider.overrideWith(_History.new),
|
||||
downloadHistoryVisibleBatchExistsProvider.overrideWith(
|
||||
(ref, request) => const {},
|
||||
),
|
||||
localLibraryCoverProvider.overrideWith(
|
||||
(ref, request) async => null,
|
||||
),
|
||||
localLibraryFirstCoverProvider.overrideWith(
|
||||
(ref, request) async => null,
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: mornye
|
||||
? MornyeTheme.build(Brightness.dark)
|
||||
: AppTheme.light(),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: LibraryTracksFolderScreen(
|
||||
mode: mode,
|
||||
playlistId: mode == LibraryTracksFolderMode.playlist
|
||||
? 'playlist'
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('First'));
|
||||
await tester.longPress(find.text('First'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('Unavailable'));
|
||||
await tester.tap(find.text('Unavailable'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.remove_circle_outline));
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, hasLength(1));
|
||||
expect(calls.single.$1, mode.name);
|
||||
expect(calls.single.$2, ['First', 'Unavailable']);
|
||||
expect(find.text('First'), findsNothing);
|
||||
expect(find.text('Unavailable'), findsNothing);
|
||||
expect(find.byIcon(Icons.remove_circle_outline), findsNothing);
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(LibraryTracksFolderScreen)),
|
||||
);
|
||||
expect(find.text(l10n.selectionSelected(2)), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final mornye in [false, true]) {
|
||||
testWidgets('playlist row queues its neighbors (Mornye: $mornye)', (
|
||||
tester,
|
||||
|
||||
Reference in new issue
Block a user