mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
perf(collections): batch Love All updates and persistence
This commit is contained in:
1 parent
460f8ebc2d
commit
20e0573d09
12 files changed
+679
-57
No files matched your search
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'loved_batch_write_test.dart' as loved;
|
||||
import 'playlist_batch_write_test.dart' as playlists;
|
||||
|
||||
void main() {
|
||||
// Register the driver's completion hook outside either fixture group.
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
group('Playlist batches', playlists.main);
|
||||
group('Loved batches', loved.main);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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 'package:spotiflac_android/services/collection_track_batch.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
import '../test/support/library_collections_benchmark.dart';
|
||||
import '../test/support/playlist_batch_benchmark.dart';
|
||||
|
||||
// Redirect only persistence to a private fixture. The notifier and the existing
|
||||
// per-track Love All operations run unchanged on the real platform SQLite.
|
||||
class _Database implements LibraryCollectionsDatabase {
|
||||
_Database(this.owner);
|
||||
final Database owner;
|
||||
|
||||
@override
|
||||
Future<void> upsertLovedEntry({
|
||||
required String trackKey,
|
||||
required String trackJson,
|
||||
required String addedAt,
|
||||
}) async {
|
||||
await owner.insert('loved_tracks', {
|
||||
'track_key': trackKey,
|
||||
'track_json': trackJson,
|
||||
'added_at': addedAt,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteLovedEntry(String trackKey) async {
|
||||
await owner.delete(
|
||||
'loved_tracks',
|
||||
where: 'track_key=?',
|
||||
whereArgs: [trackKey],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertLovedTracksBatch(List<Map<String, String>> rows) =>
|
||||
LibraryCollectionsDatabase.writeDatabaseLovedTracks(owner, rows);
|
||||
|
||||
@override
|
||||
Future<void> deleteLovedTracks(List<String> keys) =>
|
||||
LibraryCollectionsDatabase.deleteDatabaseLovedTracks(owner, keys);
|
||||
|
||||
@override
|
||||
Future<void> upsertLovedTracksFile({
|
||||
required String addedAt,
|
||||
required String rowsPath,
|
||||
required int expectedCount,
|
||||
}) => publishLovedBatch(
|
||||
database: owner,
|
||||
addedAt: addedAt,
|
||||
rowsPath: rowsPath,
|
||||
expectedCount: expectedCount,
|
||||
);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _Collections extends LibraryCollectionsNotifier {
|
||||
_Collections({required super.database});
|
||||
|
||||
@override
|
||||
LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final report = <String, dynamic>{
|
||||
'platform': Platform.operatingSystem,
|
||||
'note':
|
||||
'Five alternating pairs, real notifier and private SQLite. Heartbeat is not FPS.',
|
||||
};
|
||||
var completed = 0;
|
||||
tearDownAll(() {
|
||||
report['completed_cases'] = completed;
|
||||
binding.reportData ??= {};
|
||||
binding.reportData!['loved_batch'] = report;
|
||||
});
|
||||
|
||||
for (final count in [2000, 3000]) {
|
||||
testWidgets('Love All paired add and remove on actual SQLite ($count)', (
|
||||
tester,
|
||||
) async {
|
||||
final root = await Directory.systemTemp.createTemp('loved-batch-device-');
|
||||
final extensions = await Directory('${root.path}/extensions').create();
|
||||
final data = await Directory('${root.path}/data').create();
|
||||
final output = await Directory('${root.path}/output').create();
|
||||
await PlatformBridge.initExtensionSystem(
|
||||
extensions.path,
|
||||
data.path,
|
||||
masterKey: base64Encode(List<int>.filled(32, 1)),
|
||||
lyricsProviders: const [],
|
||||
lyricsFetchOptions: const {},
|
||||
allowedDirectories: [output.path],
|
||||
);
|
||||
expect(
|
||||
(await PlatformBridge.getBackendImplementations())['extensions'],
|
||||
'rust',
|
||||
);
|
||||
final db = await openDatabase(
|
||||
'${root.path}/collections.db',
|
||||
version: 2,
|
||||
onConfigure: (db) async {
|
||||
await db.execute('PRAGMA auto_vacuum=INCREMENTAL');
|
||||
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 {
|
||||
await db.execute(
|
||||
'CREATE TABLE loved_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_loved_tracks_added_at ON loved_tracks(added_at DESC)',
|
||||
);
|
||||
},
|
||||
);
|
||||
final tracks = await playlistBatchFixture(count);
|
||||
final baseline = <Map<String, dynamic>>[];
|
||||
final optimized = <Map<String, dynamic>>[];
|
||||
try {
|
||||
for (var sample = 0; sample < 5; sample++) {
|
||||
for (final candidate
|
||||
in sample.isEven ? [false, true] : [true, false]) {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
libraryCollectionsProvider.overrideWith(
|
||||
() => _Collections(database: _Database(db)),
|
||||
),
|
||||
],
|
||||
);
|
||||
try {
|
||||
final notifier = container.read(
|
||||
libraryCollectionsProvider.notifier,
|
||||
);
|
||||
var publications = 0;
|
||||
container.listen(
|
||||
libraryCollectionsProvider,
|
||||
(_, _) => publications++,
|
||||
);
|
||||
final add = await measureCollectionOperation(() async {
|
||||
if (candidate) {
|
||||
expect(await notifier.toggleLovedTracks(tracks), (
|
||||
removed: false,
|
||||
count: count,
|
||||
));
|
||||
return;
|
||||
}
|
||||
for (final track in tracks) {
|
||||
await notifier.toggleLoved(track);
|
||||
}
|
||||
});
|
||||
final state = container.read(libraryCollectionsProvider);
|
||||
expect(
|
||||
state.loved.map((entry) => entry.track.id),
|
||||
tracks.reversed.map((track) => track.id),
|
||||
);
|
||||
expect(publications, candidate ? 1 : tracks.length);
|
||||
expect(
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM loved_tracks'),
|
||||
),
|
||||
tracks.length,
|
||||
);
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT track_key,track_json,added_at FROM loved_tracks ORDER BY added_at DESC,rowid DESC',
|
||||
);
|
||||
expect(
|
||||
rows.map((row) => row['track_key']),
|
||||
state.loved.map((entry) => entry.key),
|
||||
);
|
||||
for (var index = 0; index < rows.length; index++) {
|
||||
expect(
|
||||
rows[index]['track_json'],
|
||||
jsonEncode(state.loved[index].track.toJson()),
|
||||
);
|
||||
expect(
|
||||
rows[index]['added_at'],
|
||||
state.loved[index].addedAt.toIso8601String(),
|
||||
);
|
||||
}
|
||||
publications = 0;
|
||||
final remove = await measureCollectionOperation(() async {
|
||||
if (candidate) {
|
||||
expect(await notifier.toggleLovedTracks(tracks), (
|
||||
removed: true,
|
||||
count: count,
|
||||
));
|
||||
return;
|
||||
}
|
||||
for (final track in tracks) {
|
||||
await notifier.removeFromLoved(trackCollectionKey(track));
|
||||
}
|
||||
});
|
||||
expect(container.read(libraryCollectionsProvider).loved, isEmpty);
|
||||
expect(await db.rawQuery('SELECT * FROM loved_tracks'), isEmpty);
|
||||
expect(publications, candidate ? 1 : tracks.length);
|
||||
(candidate ? optimized : baseline).add({
|
||||
'add': add.timing,
|
||||
'remove': remove.timing,
|
||||
'publications_per_operation': publications,
|
||||
});
|
||||
} finally {
|
||||
container.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
report['$count'] = {'baseline': baseline, 'optimized': optimized};
|
||||
completed++;
|
||||
} finally {
|
||||
await db.close();
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
await root.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/models/library_collections.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/playlist_batch_writer.dart';
|
||||
import 'package:spotiflac_android/services/collection_track_batch.dart';
|
||||
|
||||
import '../test/support/library_collections_benchmark.dart';
|
||||
import '../test/support/playlist_batch_benchmark.dart';
|
||||
@@ -86,10 +86,14 @@ void main() {
|
||||
|
||||
tearDownAll(() {
|
||||
report['completed_cases'] = completed;
|
||||
binding.reportData = {'playlist_batch_write': report};
|
||||
binding.reportData ??= {};
|
||||
binding.reportData!['playlist_batch_write'] = report;
|
||||
});
|
||||
|
||||
Future<void> publish(PreparedPlaylistBatch batch, {String id = 'p'}) async {
|
||||
Future<void> publish(
|
||||
PreparedCollectionTrackBatch batch, {
|
||||
String id = 'p',
|
||||
}) async {
|
||||
if (batch.rowsPath == null) {
|
||||
await LibraryCollectionsDatabase.writeDatabasePlaylistTracks(
|
||||
db,
|
||||
@@ -152,7 +156,7 @@ void main() {
|
||||
final result = await measureCollectionOperation(() async {
|
||||
late List<CollectionTrackEntry> additions;
|
||||
if (candidate) {
|
||||
final batch = await preparePlaylistBatch(
|
||||
final batch = await prepareCollectionTrackBatch(
|
||||
tracks: input,
|
||||
existingKeys: playlist.trackKeys,
|
||||
addedAt: now,
|
||||
@@ -213,7 +217,8 @@ void main() {
|
||||
}
|
||||
report['$count'] = {'baseline': baseline, 'optimized': optimized};
|
||||
completed++;
|
||||
binding.reportData = {'playlist_batch_write': report};
|
||||
binding.reportData ??= {};
|
||||
binding.reportData!['playlist_batch_write'] = report;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -221,7 +226,7 @@ void main() {
|
||||
testWidgets('rollback and recovery through actual SQLite ($count)', (
|
||||
tester,
|
||||
) async {
|
||||
final batch = await preparePlaylistBatch(
|
||||
final batch = await prepareCollectionTrackBatch(
|
||||
tracks: await playlistBatchFixture(count),
|
||||
existingKeys: const {},
|
||||
addedAt: now,
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_hydration.dart';
|
||||
import 'package:spotiflac_android/services/playlist_batch_writer.dart';
|
||||
import 'package:spotiflac_android/services/collection_track_batch.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
export 'package:spotiflac_android/models/library_collections.dart';
|
||||
@@ -218,6 +218,59 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
dbUpsert: _db.upsertLovedEntry,
|
||||
);
|
||||
|
||||
/// One selection is one mutation: mixed selections add only missing tracks,
|
||||
/// fully loved selections remove them, and observers see one committed state.
|
||||
Future<({bool removed, int count})> toggleLovedTracks(
|
||||
Iterable<Track> tracks,
|
||||
) => _mutate(() async {
|
||||
final selected = tracks.toList(growable: false);
|
||||
if (selected.isEmpty) return (removed: false, count: 0);
|
||||
if (selected.every(state.isLoved)) {
|
||||
final keys = selected.map(trackCollectionKey).toSet();
|
||||
await _db.deleteLovedTracks(keys.toList(growable: false));
|
||||
if (ref.mounted) {
|
||||
state = state.copyWith(
|
||||
loved: state.loved
|
||||
.where((entry) => !keys.contains(entry.key))
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
return (removed: true, count: keys.length);
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final prepared = await prepareCollectionTrackBatch(
|
||||
tracks: selected,
|
||||
existingKeys: state.loved.map((entry) => entry.key).toSet(),
|
||||
addedAt: now,
|
||||
);
|
||||
try {
|
||||
if (!ref.mounted || prepared.entries.isEmpty) {
|
||||
return (removed: false, count: 0);
|
||||
}
|
||||
final rowsPath = prepared.rowsPath;
|
||||
if (rowsPath != null) {
|
||||
await _db.upsertLovedTracksFile(
|
||||
addedAt: now.toIso8601String(),
|
||||
rowsPath: rowsPath,
|
||||
expectedCount: prepared.entries.length,
|
||||
);
|
||||
} else {
|
||||
await _db.upsertLovedTracksBatch(prepared.rows);
|
||||
}
|
||||
if (ref.mounted) {
|
||||
// The latest added track appears first, matching the original
|
||||
// per-track inserts and the database's DESC rowid tie-breaker.
|
||||
state = state.copyWith(
|
||||
loved: [...prepared.entries.reversed, ...state.loved],
|
||||
);
|
||||
}
|
||||
return (removed: false, count: prepared.entries.length);
|
||||
} finally {
|
||||
await prepared.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
Future<bool> toggleFavoriteArtist({
|
||||
required String artistId,
|
||||
required String? providerId,
|
||||
@@ -422,7 +475,7 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final prepared = await preparePlaylistBatch(
|
||||
final prepared = await prepareCollectionTrackBatch(
|
||||
tracks: tracks,
|
||||
existingKeys: playlist.trackKeys,
|
||||
addedAt: now,
|
||||
|
||||
@@ -15,10 +15,10 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
const _workerThreshold = 1024;
|
||||
const _nativeThreshold = 2048;
|
||||
var _attachmentSequence = 0;
|
||||
final _log = AppLogger('PlaylistBatch');
|
||||
final _log = AppLogger('CollectionTrackBatch');
|
||||
|
||||
class PreparedPlaylistBatch {
|
||||
const PreparedPlaylistBatch({
|
||||
class PreparedCollectionTrackBatch {
|
||||
const PreparedCollectionTrackBatch({
|
||||
required this.entries,
|
||||
required this.duplicates,
|
||||
required this.rows,
|
||||
@@ -40,7 +40,7 @@ class PreparedPlaylistBatch {
|
||||
|
||||
/// Deduplication and encoding share one worker. Large native batches return a
|
||||
/// file path instead of transferring thousands of JSON maps back through UI.
|
||||
Future<PreparedPlaylistBatch> preparePlaylistBatch({
|
||||
Future<PreparedCollectionTrackBatch> prepareCollectionTrackBatch({
|
||||
required Iterable<Track> tracks,
|
||||
required Set<String> existingKeys,
|
||||
required DateTime addedAt,
|
||||
@@ -69,7 +69,7 @@ Future<PreparedPlaylistBatch> preparePlaylistBatch({
|
||||
}
|
||||
}
|
||||
|
||||
PreparedPlaylistBatch _prepare(
|
||||
PreparedCollectionTrackBatch _prepare(
|
||||
List<Track> tracks,
|
||||
Set<String> existingKeys,
|
||||
DateTime addedAt,
|
||||
@@ -118,7 +118,7 @@ PreparedPlaylistBatch _prepare(
|
||||
});
|
||||
}
|
||||
}
|
||||
return PreparedPlaylistBatch(
|
||||
return PreparedCollectionTrackBatch(
|
||||
entries: entries,
|
||||
duplicates: duplicates,
|
||||
rows: rows,
|
||||
@@ -137,7 +137,45 @@ Future<void> publishPlaylistBatch({
|
||||
required int expectedCount,
|
||||
Future<Map<String, dynamic>> Function(Map<String, dynamic>)? nativeJobRunner,
|
||||
Directory? temporaryDirectory,
|
||||
}) => _publishTrackBatch(
|
||||
database: database,
|
||||
playlistId: playlistId,
|
||||
updatedAt: updatedAt,
|
||||
rowsPath: rowsPath,
|
||||
expectedCount: expectedCount,
|
||||
nativeJobRunner: nativeJobRunner,
|
||||
temporaryDirectory: temporaryDirectory,
|
||||
);
|
||||
|
||||
Future<void> publishLovedBatch({
|
||||
required Database database,
|
||||
required String addedAt,
|
||||
required String rowsPath,
|
||||
required int expectedCount,
|
||||
Future<Map<String, dynamic>> Function(Map<String, dynamic>)? nativeJobRunner,
|
||||
Directory? temporaryDirectory,
|
||||
}) => _publishTrackBatch(
|
||||
database: database,
|
||||
playlistId: null,
|
||||
updatedAt: addedAt,
|
||||
rowsPath: rowsPath,
|
||||
expectedCount: expectedCount,
|
||||
nativeJobRunner: nativeJobRunner,
|
||||
temporaryDirectory: temporaryDirectory,
|
||||
);
|
||||
|
||||
Future<void> _publishTrackBatch({
|
||||
required Database database,
|
||||
required String? playlistId,
|
||||
required String updatedAt,
|
||||
required String rowsPath,
|
||||
required int expectedCount,
|
||||
required Future<Map<String, dynamic>> Function(Map<String, dynamic>)?
|
||||
nativeJobRunner,
|
||||
required Directory? temporaryDirectory,
|
||||
}) async {
|
||||
// The native staging format contains only ordered keys and track JSON.
|
||||
// Both destinations share it; only this owner chooses the live table.
|
||||
final directory = await (temporaryDirectory ?? await getTemporaryDirectory())
|
||||
.createTemp('playlist-stage-');
|
||||
final stagedPath = '${directory.path}/playlist_additions.db';
|
||||
@@ -153,7 +191,7 @@ Future<void> publishPlaylistBatch({
|
||||
result['published'] != true ||
|
||||
result['count'] != expectedCount ||
|
||||
result['path'] != stagedPath) {
|
||||
throw const FormatException('Incomplete playlist staging');
|
||||
throw const FormatException('Incomplete collection track staging');
|
||||
}
|
||||
final alias = 'playlist_additions_${_attachmentSequence++}';
|
||||
await database.execute('ATTACH DATABASE ? AS $alias', [stagedPath]);
|
||||
@@ -161,6 +199,16 @@ Future<void> publishPlaylistBatch({
|
||||
var committed = false;
|
||||
try {
|
||||
await sqlite.transactionWithBusyRetry(database, (txn) async {
|
||||
if (playlistId == null) {
|
||||
await txn.execute(
|
||||
'INSERT OR REPLACE INTO main.loved_tracks '
|
||||
'(track_key,track_json,added_at) '
|
||||
'SELECT track_key,track_json,? FROM $alias.playlist_additions '
|
||||
'ORDER BY position',
|
||||
[updatedAt],
|
||||
);
|
||||
return;
|
||||
}
|
||||
await txn.execute(
|
||||
'INSERT OR REPLACE INTO main.playlist_tracks '
|
||||
'(playlist_id,track_key,track_json,added_at) '
|
||||
@@ -182,7 +230,7 @@ Future<void> publishPlaylistBatch({
|
||||
detached = true;
|
||||
} on NativeSqliteSnapshotDetachException {
|
||||
if (!committed) rethrow;
|
||||
_log.w('Playlist saved; retaining staging after failed DETACH');
|
||||
_log.w('Collection saved; retaining staging after failed DETACH');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -8,7 +8,7 @@ import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/models/library_collections.dart';
|
||||
import 'package:spotiflac_android/services/collection_restore_codec.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/playlist_batch_writer.dart';
|
||||
import 'package:spotiflac_android/services/collection_track_batch.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
import 'package:spotiflac_android/services/sqlite_native_snapshot.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -631,6 +631,64 @@ class LibraryCollectionsDatabase {
|
||||
Future<void> deleteLovedEntry(String trackKey) =>
|
||||
_deleteEntry(_tableLoved, 'track', trackKey);
|
||||
|
||||
Future<void> upsertLovedTracksBatch(List<Map<String, String>> tracks) async {
|
||||
if (tracks.isEmpty) return;
|
||||
await writeDatabaseLovedTracks(await database, tracks);
|
||||
}
|
||||
|
||||
static Future<void> writeDatabaseLovedTracks(
|
||||
Database db,
|
||||
List<Map<String, String>> tracks,
|
||||
) async {
|
||||
if (tracks.isEmpty) return;
|
||||
await sqlite.transactionWithBusyRetry(db, (txn) async {
|
||||
for (var start = 0; start < tracks.length; start += 128) {
|
||||
final batch = txn.batch();
|
||||
for (final row in tracks.skip(start).take(128)) {
|
||||
batch.insert(
|
||||
_tableLoved,
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertLovedTracksFile({
|
||||
required String addedAt,
|
||||
required String rowsPath,
|
||||
required int expectedCount,
|
||||
}) async => publishLovedBatch(
|
||||
database: await database,
|
||||
addedAt: addedAt,
|
||||
rowsPath: rowsPath,
|
||||
expectedCount: expectedCount,
|
||||
);
|
||||
|
||||
Future<void> deleteLovedTracks(List<String> keys) async {
|
||||
if (keys.isEmpty) return;
|
||||
await deleteDatabaseLovedTracks(await database, keys);
|
||||
}
|
||||
|
||||
static Future<void> deleteDatabaseLovedTracks(
|
||||
Database db,
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertFavoriteArtistEntry({
|
||||
required String artistKey,
|
||||
required String artistJson,
|
||||
|
||||
@@ -339,38 +339,19 @@ Future<void> loveAllTracks(
|
||||
WidgetRef ref,
|
||||
List<Track> tracks,
|
||||
) async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
final state = ref.read(libraryCollectionsProvider);
|
||||
final allLoved = tracks.every((t) => state.isLoved(t));
|
||||
|
||||
if (allLoved) {
|
||||
for (final track in tracks) {
|
||||
final key = trackCollectionKey(track);
|
||||
await notifier.removeFromLoved(key);
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarRemovedTracksFromLoved(tracks.length),
|
||||
),
|
||||
if (tracks.isEmpty) return;
|
||||
final result = await ref
|
||||
.read(libraryCollectionsProvider.notifier)
|
||||
.toggleLovedTracks(tracks);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.removed
|
||||
? context.l10n.snackbarRemovedTracksFromLoved(result.count)
|
||||
: context.l10n.snackbarAddedTracksToLoved(result.count),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
int addedCount = 0;
|
||||
for (final track in tracks) {
|
||||
if (!state.isLoved(track)) {
|
||||
await notifier.toggleLoved(track);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/theme/app_theme.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/widgets/track_list_tile.dart';
|
||||
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
|
||||
|
||||
const _track = Track(
|
||||
id: 'example-track',
|
||||
@@ -31,6 +32,7 @@ class _Settings extends SettingsNotifier {
|
||||
}
|
||||
|
||||
class _Collections extends LibraryCollectionsNotifier {
|
||||
var _batchCalls = 0;
|
||||
@override
|
||||
LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true);
|
||||
|
||||
@@ -42,6 +44,16 @@ class _Collections extends LibraryCollectionsNotifier {
|
||||
|
||||
void publishWishlist() => state = state.copyWith(wishlist: [_entry]);
|
||||
void publishLoved() => state = state.copyWith(loved: [_entry]);
|
||||
|
||||
@override
|
||||
Future<({bool removed, int count})> toggleLovedTracks(
|
||||
Iterable<Track> tracks,
|
||||
) async {
|
||||
_batchCalls++;
|
||||
final removed = state.loved.isNotEmpty;
|
||||
state = state.copyWith(loved: removed ? [] : [_entry]);
|
||||
return (removed: removed, count: 1);
|
||||
}
|
||||
}
|
||||
|
||||
class _Recent extends RecentAccessNotifier {
|
||||
@@ -140,6 +152,48 @@ void main() {
|
||||
);
|
||||
|
||||
for (final mornye in [false, true]) {
|
||||
testWidgets(
|
||||
'Love All action keeps its add/remove feedback in Mornye=$mornye',
|
||||
(tester) async {
|
||||
final collections = _Collections();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
libraryCollectionsProvider.overrideWith(() => collections),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await tester.pumpWidget(
|
||||
_app(
|
||||
container,
|
||||
Consumer(
|
||||
builder: (context, ref, _) => TextButton(
|
||||
onPressed: () => loveAllTracks(context, ref, [_track]),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
),
|
||||
mornye: mornye,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Apply'));
|
||||
await tester.pumpAndSettle();
|
||||
final context = tester.element(find.text('Apply'));
|
||||
expect(
|
||||
find.text(context.l10n.snackbarAddedTracksToLoved(1)),
|
||||
findsOneWidget,
|
||||
);
|
||||
ScaffoldMessenger.of(context).removeCurrentSnackBar();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Apply'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(context.l10n.snackbarRemovedTracksFromLoved(1)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(collections._batchCalls, 2);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
for (final album in [false, true]) {
|
||||
testWidgets('Love All isolates the $album page in Mornye=$mornye', (
|
||||
tester,
|
||||
|
||||
@@ -177,6 +177,22 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
|
||||
playlists[playlistId]!['updated_at'] = playlistUpdatedAt;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> upsertLovedTracksBatch(List<Map<String, String>> tracks) =>
|
||||
_write('bulk-loved:add', () {
|
||||
for (final row in tracks) {
|
||||
loved[row['track_key']!] = Map<String, dynamic>.from(row);
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> deleteLovedTracks(List<String> keys) =>
|
||||
_write('bulk-loved:remove', () {
|
||||
for (final key in keys) {
|
||||
loved.remove(key);
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -203,6 +219,93 @@ ProviderContainer _container(_CollectionsDatabase db) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final failFirst in [false, true]) {
|
||||
test(
|
||||
'Love All is atomic and serializes repeated clicks (failure: $failFirst)',
|
||||
() async {
|
||||
final db = _CollectionsDatabase()..failNextWrite = failFirst;
|
||||
final container = _container(db);
|
||||
final notifier = container.read(libraryCollectionsProvider.notifier);
|
||||
var publications = 0;
|
||||
container.listen(libraryCollectionsProvider, (_, _) => publications++);
|
||||
final selected = [_track('1'), _track('1'), _track('2')];
|
||||
final first = notifier.toggleLovedTracks(selected);
|
||||
final failure = failFirst ? expectLater(first, throwsStateError) : null;
|
||||
await db.writeStarted.future;
|
||||
publications = 0;
|
||||
expect(container.read(libraryCollectionsProvider).loved, isEmpty);
|
||||
final second = notifier.toggleLovedTracks(selected);
|
||||
expect(db.events, ['bulk-loved:add']);
|
||||
db.releaseWrite.complete();
|
||||
if (failure != null) {
|
||||
await failure;
|
||||
} else {
|
||||
expect(await first, (removed: false, count: 2));
|
||||
}
|
||||
expect(await second, (removed: !failFirst, count: 2));
|
||||
expect(publications, failFirst ? 1 : 2);
|
||||
expect(
|
||||
container
|
||||
.read(libraryCollectionsProvider)
|
||||
.loved
|
||||
.map((entry) => entry.track.id),
|
||||
failFirst ? ['2', '1'] : isEmpty,
|
||||
);
|
||||
expect(db.loved.length, failFirst ? 2 : 0);
|
||||
expect(db.events, [
|
||||
'bulk-loved:add',
|
||||
failFirst ? 'bulk-loved:add' : 'bulk-loved:remove',
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'mixed Love All preserves existing metadata and unrelated loved tracks',
|
||||
() async {
|
||||
final db = _CollectionsDatabase();
|
||||
for (final id in ['existing', 'unrelated']) {
|
||||
db.loved[trackCollectionKey(_track(id))] = {
|
||||
'track_key': trackCollectionKey(_track(id)),
|
||||
'track_json': jsonEncode(_track(id).toJson()),
|
||||
'added_at': '2026-01-01T00:00:00Z',
|
||||
};
|
||||
}
|
||||
final container = _container(db);
|
||||
final notifier = container.read(libraryCollectionsProvider.notifier);
|
||||
final selected = [_track('existing'), _track('new'), _track('new')];
|
||||
final add = notifier.toggleLovedTracks(selected);
|
||||
await db.writeStarted.future;
|
||||
db.releaseWrite.complete();
|
||||
expect(await add, (removed: false, count: 1));
|
||||
expect(
|
||||
container
|
||||
.read(libraryCollectionsProvider)
|
||||
.loved
|
||||
.map((entry) => entry.track.id),
|
||||
['new', 'existing', 'unrelated'],
|
||||
);
|
||||
expect(
|
||||
db.loved[trackCollectionKey(_track('existing'))]!['added_at'],
|
||||
'2026-01-01T00:00:00Z',
|
||||
);
|
||||
expect(await notifier.toggleLovedTracks(selected), (
|
||||
removed: true,
|
||||
count: 2,
|
||||
));
|
||||
expect(
|
||||
container.read(libraryCollectionsProvider).loved.single.track.id,
|
||||
'unrelated',
|
||||
);
|
||||
final before = db.events.length;
|
||||
expect(await notifier.toggleLovedTracks(const []), (
|
||||
removed: false,
|
||||
count: 0,
|
||||
));
|
||||
expect(db.events.length, before);
|
||||
},
|
||||
);
|
||||
|
||||
for (final failFirst in [false, true]) {
|
||||
test(
|
||||
'bulk additions publish after persistence and serialize duplicates (failure: $failFirst)',
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/playlist_batch_writer.dart';
|
||||
import 'package:spotiflac_android/services/collection_track_batch.dart';
|
||||
|
||||
import 'support/library_collections_benchmark.dart';
|
||||
import 'support/playlist_batch_benchmark.dart';
|
||||
@@ -20,7 +20,7 @@ void main() {
|
||||
for (final worker in index.isEven ? [false, true] : [true, false]) {
|
||||
if (worker) {
|
||||
final result = await measureCollectionOperation(
|
||||
() => preparePlaylistBatch(
|
||||
() => prepareCollectionTrackBatch(
|
||||
tracks: tracks,
|
||||
existingKeys: const {},
|
||||
addedAt: DateTime.utc(2026, 10, 8),
|
||||
|
||||
@@ -3,7 +3,8 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/playlist_batch_writer.dart';
|
||||
import 'package:spotiflac_android/services/collection_track_batch.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
|
||||
import 'support/sqlite_process_database.dart';
|
||||
|
||||
@@ -45,6 +46,9 @@ void main() {
|
||||
'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))',
|
||||
);
|
||||
await live.execute('CREATE TABLE published(track_key TEXT)');
|
||||
await live.execute(
|
||||
'CREATE TABLE loved_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL)',
|
||||
);
|
||||
await live.execute(
|
||||
'CREATE TRIGGER audit AFTER INSERT ON playlist_tracks BEGIN INSERT INTO published VALUES(new.track_key); END',
|
||||
);
|
||||
@@ -134,6 +138,83 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
for (final fail in [false, true]) {
|
||||
test('shared staging publishes Loved atomically (failure: $fail)', () async {
|
||||
if (fail) {
|
||||
await live.execute(
|
||||
"CREATE TRIGGER fail_loved BEFORE INSERT ON loved_tracks WHEN new.track_key='new-b' BEGIN SELECT RAISE(ABORT,'denied'); END",
|
||||
);
|
||||
}
|
||||
final operation = publishLovedBatch(
|
||||
database: live,
|
||||
addedAt: 'new',
|
||||
rowsPath: input,
|
||||
expectedCount: 2,
|
||||
nativeJobRunner: stage,
|
||||
temporaryDirectory: root,
|
||||
);
|
||||
if (fail) {
|
||||
await expectLater(operation, throwsA(isA<StateError>()));
|
||||
expect(await live.rawQuery('SELECT * FROM loved_tracks'), isEmpty);
|
||||
} else {
|
||||
await operation;
|
||||
expect(
|
||||
(await live.rawQuery(
|
||||
'SELECT track_key FROM loved_tracks ORDER BY added_at DESC,rowid DESC',
|
||||
)).map((row) => row['track_key']),
|
||||
['new-b', 'new-a'],
|
||||
);
|
||||
}
|
||||
expect(await File(retainedStage!).exists(), isFalse);
|
||||
expect((await live.rawQuery('PRAGMA database_list')).length, 1);
|
||||
expect(
|
||||
(await live.rawQuery(
|
||||
'SELECT track_key FROM playlist_tracks',
|
||||
)).single['track_key'],
|
||||
'existing',
|
||||
);
|
||||
expect(
|
||||
(await live.rawQuery(
|
||||
"SELECT updated_at FROM playlists WHERE id='p'",
|
||||
)).single['updated_at'],
|
||||
'old',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('bounded Loved inserts and deletes roll back earlier chunks', () async {
|
||||
final rows = List.generate(
|
||||
400,
|
||||
(index) => <String, String>{
|
||||
'track_key': 'key-$index',
|
||||
'track_json': '{}',
|
||||
'added_at': 'new',
|
||||
},
|
||||
);
|
||||
await live.execute(
|
||||
"CREATE TRIGGER fail_loved BEFORE INSERT ON loved_tracks WHEN new.track_key='key-300' BEGIN SELECT RAISE(ABORT,'denied'); END",
|
||||
);
|
||||
await expectLater(
|
||||
LibraryCollectionsDatabase.writeDatabaseLovedTracks(live, rows),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(await live.rawQuery('SELECT * FROM loved_tracks'), isEmpty);
|
||||
await live.execute('DROP TRIGGER fail_loved');
|
||||
await LibraryCollectionsDatabase.writeDatabaseLovedTracks(live, rows);
|
||||
await live.execute(
|
||||
"CREATE TRIGGER fail_delete BEFORE DELETE ON loved_tracks WHEN old.track_key='key-300' BEGIN SELECT RAISE(ABORT,'denied'); END",
|
||||
);
|
||||
final keys = rows.map((row) => row['track_key']!).toList();
|
||||
await expectLater(
|
||||
LibraryCollectionsDatabase.deleteDatabaseLovedTracks(live, keys),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect((await live.rawQuery('SELECT * FROM loved_tracks')).length, 400);
|
||||
await live.execute('DROP TRIGGER fail_delete');
|
||||
await LibraryCollectionsDatabase.deleteDatabaseLovedTracks(live, keys);
|
||||
expect(await live.rawQuery('SELECT * FROM loved_tracks'), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'timestamp failure rolls back all inserted tracks and trigger effects',
|
||||
() async {
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/playlist_batch_writer.dart';
|
||||
import 'package:spotiflac_android/services/collection_track_batch.dart';
|
||||
|
||||
import 'support/playlist_batch_benchmark.dart';
|
||||
|
||||
@@ -37,7 +37,7 @@ void main() {
|
||||
final tracks = [...fixture, fixture[1], fixture[3]];
|
||||
final keys = {'example:0', 'example:1'};
|
||||
final baseline = inlinePlaylistBatch(tracks, keys, now);
|
||||
final prepared = await preparePlaylistBatch(
|
||||
final prepared = await prepareCollectionTrackBatch(
|
||||
tracks: tracks,
|
||||
existingKeys: keys,
|
||||
addedAt: now,
|
||||
@@ -85,7 +85,7 @@ void main() {
|
||||
albumName: 'Album',
|
||||
duration: 1,
|
||||
);
|
||||
final prepared = await preparePlaylistBatch(
|
||||
final prepared = await prepareCollectionTrackBatch(
|
||||
tracks: [
|
||||
track('first', 'one', ' xx001 '),
|
||||
track('second', 'two', 'XX001'),
|
||||
@@ -101,7 +101,7 @@ void main() {
|
||||
|
||||
test('mostly duplicate batches retain the small persistence route', () async {
|
||||
final fixture = await playlistBatchFixture(12);
|
||||
final prepared = await preparePlaylistBatch(
|
||||
final prepared = await prepareCollectionTrackBatch(
|
||||
tracks: [for (var index = 0; index < 256; index++) ...fixture],
|
||||
existingKeys: const {},
|
||||
addedAt: now,
|
||||
@@ -118,7 +118,7 @@ void main() {
|
||||
test('failed encoding removes its partial row file', () async {
|
||||
final fixture = await playlistBatchFixture(2500);
|
||||
await expectLater(
|
||||
preparePlaylistBatch(
|
||||
prepareCollectionTrackBatch(
|
||||
tracks: [...fixture, _InvalidTrack()],
|
||||
existingKeys: const {},
|
||||
addedAt: now,
|
||||
|
||||
Reference in new issue
Block a user