mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
perf(collections): hydrate native snapshots on bounded workers
This commit is contained in:
1 parent
c03be45ed9
commit
4d0fe68f17
13 files changed
+2915
-181
No files matched your search
@@ -0,0 +1,290 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
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/library_collections_hydration.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
import '../test/support/library_collections_benchmark.dart';
|
||||
|
||||
Future<Database> _database(String path) => openDatabase(
|
||||
path,
|
||||
version: 2,
|
||||
onConfigure: (db) async {
|
||||
await db.execute('PRAGMA foreign_keys=ON');
|
||||
await db.rawQuery('PRAGMA journal_mode=WAL');
|
||||
await db.rawQuery('PRAGMA busy_timeout=5000');
|
||||
},
|
||||
onCreate: (db, _) async {
|
||||
final batch = db.batch();
|
||||
for (final sql in [
|
||||
'CREATE TABLE wishlist_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL)',
|
||||
'CREATE TABLE loved_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL)',
|
||||
'CREATE TABLE favorite_artists(artist_key TEXT PRIMARY KEY,artist_json TEXT NOT NULL,added_at TEXT NOT NULL)',
|
||||
'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)',
|
||||
'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)',
|
||||
'CREATE INDEX idx_wishlist_tracks_added_at ON wishlist_tracks(added_at DESC)',
|
||||
'CREATE INDEX idx_loved_tracks_added_at ON loved_tracks(added_at DESC)',
|
||||
'CREATE INDEX idx_favorite_artists_added_at ON favorite_artists(added_at DESC)',
|
||||
'CREATE INDEX idx_playlists_created_at ON playlists(created_at DESC)',
|
||||
'CREATE INDEX idx_playlist_tracks_playlist_id ON playlist_tracks(playlist_id)',
|
||||
]) {
|
||||
batch.execute(sql);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
},
|
||||
);
|
||||
|
||||
Future<Map<String, dynamic>> _databaseFixture(int count) => Isolate.run(() {
|
||||
final tracks = [
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
'key': 'example:$index',
|
||||
'addedAt': '2026-10-0${index % 3 + 1}T00:00:00Z',
|
||||
'track': {
|
||||
'id': '$index',
|
||||
'name': '音楽 🎵 Track $index',
|
||||
'artistName': 'Artist ${index % 97}',
|
||||
'albumName': 'Album ${index ~/ 12}',
|
||||
'duration': 180,
|
||||
'coverUrl': 'https://example.test/cover-$index.jpg',
|
||||
'source': 'example',
|
||||
},
|
||||
},
|
||||
];
|
||||
return {
|
||||
'wishlist': tracks,
|
||||
'loved': tracks.take(32).toList(),
|
||||
'favoriteArtists': [
|
||||
{
|
||||
'key': 'example:artist',
|
||||
'artistId': 'artist',
|
||||
'providerId': 'example',
|
||||
'name': 'İstanbul',
|
||||
'addedAt': '2026-10-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
'playlists': [
|
||||
for (var index = 0; index < 4; index++)
|
||||
{
|
||||
'id': 'p-$index',
|
||||
'name': 'Playlist $index',
|
||||
'createdAt': '2026-10-01T00:00:00Z',
|
||||
'updatedAt': '2026-10-01T00:00:00Z',
|
||||
if (index == 1) 'coverImagePath': '/covers/custom.jpg',
|
||||
'tracks': tracks.skip(index * (count ~/ 4)).take(count ~/ 4).toList(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
Future<LibraryCollectionsState> _legacyRead(Database db) async {
|
||||
final snapshot = LibraryCollectionsSnapshot(
|
||||
wishlistRows: await db.rawQuery(
|
||||
'SELECT * FROM wishlist_tracks ORDER BY added_at DESC,rowid DESC',
|
||||
),
|
||||
lovedRows: await db.rawQuery(
|
||||
'SELECT * FROM loved_tracks ORDER BY added_at DESC,rowid DESC',
|
||||
),
|
||||
playlistRows: await db.rawQuery(
|
||||
"""SELECT p.*, CASE WHEN p.cover_image_path IS NULL OR p.cover_image_path='' THEN
|
||||
(SELECT pt.track_json FROM playlist_tracks pt WHERE pt.playlist_id=p.id ORDER BY pt.added_at ASC,pt.rowid ASC LIMIT 1)
|
||||
END AS preview_track_json FROM playlists p ORDER BY p.created_at DESC,p.rowid DESC""",
|
||||
),
|
||||
playlistTrackRows: await db.rawQuery(
|
||||
'SELECT playlist_id,track_key FROM playlist_tracks ORDER BY playlist_id ASC,rowid ASC',
|
||||
),
|
||||
favoriteArtistRows: await db.rawQuery(
|
||||
'SELECT * FROM favorite_artists ORDER BY added_at DESC,rowid DESC',
|
||||
),
|
||||
);
|
||||
return decodeLibraryCollectionsSnapshot(snapshot);
|
||||
}
|
||||
|
||||
Future<String> _digest(LibraryCollectionsState state) => Isolate.run(
|
||||
() => sha256
|
||||
.convert(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'state': state.toJson(),
|
||||
'membership': [
|
||||
for (final playlist in state.playlists)
|
||||
{
|
||||
'id': playlist.id,
|
||||
'keys': playlist.trackKeys.toList()..sort(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toString(),
|
||||
);
|
||||
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final report = <String, dynamic>{
|
||||
'platform': Platform.operatingSystem,
|
||||
'note': 'Five alternating paired samples; not physical-device FPS/RAM.',
|
||||
};
|
||||
void publish() {
|
||||
binding.reportData ??= <String, dynamic>{};
|
||||
binding.reportData!['collection_workers'] = report;
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'real sqflite native snapshot and playlist read preserve rows and cleanup',
|
||||
(tester) async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'collection-channel-read-',
|
||||
);
|
||||
final db = await _database('${directory.path}/library_collections.db');
|
||||
try {
|
||||
await LibraryCollectionsDatabase.replaceDatabaseFromBackup(
|
||||
db,
|
||||
await _databaseFixture(8000),
|
||||
);
|
||||
await db.execute('CREATE TABLE projection_write_probe(value INTEGER)');
|
||||
await db.execute('INSERT INTO projection_write_probe VALUES(0)');
|
||||
var writes = 0;
|
||||
var verifyIsolation = false;
|
||||
Future<Map<String, dynamic>> nativeRead(
|
||||
Map<String, dynamic> request,
|
||||
) async {
|
||||
expect(request['collections_path'], isNot(db.path));
|
||||
if (verifyIsolation) {
|
||||
final databases = await db.rawQuery('PRAGMA database_list');
|
||||
expect(
|
||||
databases.any(
|
||||
(row) => (row['name'] as String).startsWith('collection_read_'),
|
||||
),
|
||||
false,
|
||||
reason: 'sqflite must detach the private file before native IO',
|
||||
);
|
||||
// Verify outside the paired timing samples: live writes cannot
|
||||
// alter the detached projection and remain on the same engine.
|
||||
await db.execute('UPDATE projection_write_probe SET value=value+1');
|
||||
writes++;
|
||||
}
|
||||
return PlatformBridge.runNativeDataJob(request);
|
||||
}
|
||||
|
||||
final expectedDigest = await _digest(await _legacyRead(db));
|
||||
final baseline = <Map<String, int>>[];
|
||||
final native = <Map<String, int>>[];
|
||||
for (var sample = 0; sample < 5; sample++) {
|
||||
for (final useNative
|
||||
in sample.isEven ? [false, true] : [true, false]) {
|
||||
final result =
|
||||
await measureCollectionOperation<LibraryCollectionsState>(
|
||||
() async {
|
||||
if (!useNative) return _legacyRead(db);
|
||||
final snapshot =
|
||||
await LibraryCollectionsDatabase.readDatabaseSnapshot(
|
||||
db,
|
||||
nativeJobRunner: nativeRead,
|
||||
);
|
||||
expect(snapshot.nativeRowsPath, isNotNull);
|
||||
final staged = snapshot.nativeRowsDirectory!;
|
||||
final state = await hydrateLibraryCollectionsSnapshot(
|
||||
snapshot,
|
||||
);
|
||||
expect(await Directory(staged).exists(), isFalse);
|
||||
return state;
|
||||
},
|
||||
);
|
||||
expect(await _digest(result.value), expectedDigest);
|
||||
(useNative ? native : baseline).add(result.timing);
|
||||
}
|
||||
}
|
||||
verifyIsolation = true;
|
||||
final rows =
|
||||
await LibraryCollectionsDatabase.readDatabasePlaylistTracks(
|
||||
db,
|
||||
'p-0',
|
||||
nativeJobRunner: nativeRead,
|
||||
);
|
||||
expect(rows.nativeRowsPath, isNotNull);
|
||||
final expectedTracks = await db.rawQuery(
|
||||
'SELECT * FROM playlist_tracks WHERE playlist_id=? ORDER BY added_at ASC,rowid ASC',
|
||||
['p-0'],
|
||||
);
|
||||
final expected = await hydratePlaylistTracks(
|
||||
expectedTracks,
|
||||
knownKeys: expectedTracks
|
||||
.map((row) => row['track_key'] as String)
|
||||
.toSet(),
|
||||
);
|
||||
final staged = rows.nativeRowsDirectory!;
|
||||
final tracks = await hydratePlaylistTracksSnapshot(
|
||||
rows,
|
||||
knownKeys: expected.trackKeys,
|
||||
);
|
||||
expect(
|
||||
jsonEncode(tracks.tracks.map((entry) => entry.toJson()).toList()),
|
||||
jsonEncode(expected.tracks.map((entry) => entry.toJson()).toList()),
|
||||
);
|
||||
expect(tracks.membershipUnchanged, isTrue);
|
||||
expect(await Directory(staged).exists(), isFalse);
|
||||
expect(await db.rawQuery('SELECT value FROM projection_write_probe'), [
|
||||
{'value': writes},
|
||||
]);
|
||||
expect(await db.rawQuery('PRAGMA integrity_check'), [
|
||||
{'integrity_check': 'ok'},
|
||||
]);
|
||||
report['native_database_read'] = {
|
||||
'tracks': 8000,
|
||||
'membership_rows': 8000,
|
||||
'baseline': baseline,
|
||||
'native': native,
|
||||
'digest': expectedDigest,
|
||||
'note':
|
||||
'Full original sqflite queries+caller hydration versus same-engine private SQL projection+native file+worker, five alternating pairs; native never opens the live DB.',
|
||||
};
|
||||
publish();
|
||||
} finally {
|
||||
await db.close();
|
||||
await directory.delete(recursive: true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'collection snapshot worker preserves parity and caller heartbeat',
|
||||
(tester) async {
|
||||
final result = await benchmarkCollectionHydration();
|
||||
final samples = result['worker'] as List<Map<String, int>>;
|
||||
expect(samples.every((sample) => sample['heartbeat_count']! > 0), isTrue);
|
||||
report['collection_hydration'] = result;
|
||||
publish();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('large playlist search preserves original ranked pages', (
|
||||
tester,
|
||||
) async {
|
||||
report['playlist_search'] = await benchmarkPlaylistSearch();
|
||||
publish();
|
||||
});
|
||||
|
||||
testWidgets('metadata-only playlist copies retain all membership indices', (
|
||||
tester,
|
||||
) async {
|
||||
report['playlist_metadata_copy'] = await benchmarkPlaylistMetadataCopy();
|
||||
publish();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'collection export worker preserves model JSON and caller heartbeat',
|
||||
(tester) async {
|
||||
report['collection_export'] = await benchmarkCollectionExport();
|
||||
report['collection_json_export'] = await benchmarkCollectionJsonExport();
|
||||
publish();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/library_collections.dart';
|
||||
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/utils/logger.dart';
|
||||
|
||||
export 'package:spotiflac_android/models/library_collections.dart';
|
||||
@@ -58,84 +59,16 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
await _db.migrateFromSharedPreferences();
|
||||
if (!ref.mounted) return;
|
||||
final snapshot = await _db.loadSnapshot();
|
||||
if (!ref.mounted) return;
|
||||
|
||||
final wishlist = <CollectionTrackEntry>[];
|
||||
for (final row in snapshot.wishlistRows) {
|
||||
final parsed = _parseTrackEntryRow(row);
|
||||
if (parsed != null) {
|
||||
wishlist.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
final loved = <CollectionTrackEntry>[];
|
||||
for (final row in snapshot.lovedRows) {
|
||||
final parsed = _parseTrackEntryRow(row);
|
||||
if (parsed != null) {
|
||||
loved.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
final favoriteArtists = <CollectionArtistEntry>[];
|
||||
for (final row in snapshot.favoriteArtistRows) {
|
||||
final parsed = _parseArtistEntryRow(row);
|
||||
if (parsed != null) {
|
||||
favoriteArtists.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
final trackKeysByPlaylist = <String, Set<String>>{};
|
||||
for (final row in snapshot.playlistTrackRows) {
|
||||
final playlistId = row['playlist_id'] as String?;
|
||||
if (playlistId == null || playlistId.isEmpty) continue;
|
||||
final trackKey = row['track_key'] as String?;
|
||||
if (trackKey == null || trackKey.isEmpty) continue;
|
||||
trackKeysByPlaylist.putIfAbsent(playlistId, () => {}).add(trackKey);
|
||||
}
|
||||
|
||||
final playlists = <UserPlaylistCollection>[];
|
||||
for (final row in snapshot.playlistRows) {
|
||||
final id = row['id'] as String?;
|
||||
if (id == null || id.isEmpty) continue;
|
||||
|
||||
final createdAtRaw = row['created_at'] as String?;
|
||||
final updatedAtRaw = row['updated_at'] as String?;
|
||||
final createdAt =
|
||||
DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now();
|
||||
final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt;
|
||||
String? previewCover;
|
||||
final previewTrackJson = row['preview_track_json'] as String?;
|
||||
if (previewTrackJson != null && previewTrackJson.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(previewTrackJson);
|
||||
if (decoded is Map) {
|
||||
previewCover = decoded['coverUrl']?.toString();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
playlists.add(
|
||||
UserPlaylistCollection(
|
||||
id: id,
|
||||
name: row['name'] as String? ?? '',
|
||||
coverImagePath: row['cover_image_path'] as String?,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
tracks: const <CollectionTrackEntry>[],
|
||||
previewCover: previewCover,
|
||||
tracksLoaded: false,
|
||||
trackKeys: trackKeysByPlaylist[id],
|
||||
),
|
||||
try {
|
||||
if (!ref.mounted) return;
|
||||
final loaded = await hydrateLibraryCollectionsSnapshot(snapshot);
|
||||
if (ref.mounted) state = loaded;
|
||||
} finally {
|
||||
await cleanupCollectionRows(
|
||||
nativeRowsPath: snapshot.nativeRowsPath,
|
||||
nativeRowsDirectory: snapshot.nativeRowsDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
state = LibraryCollectionsState(
|
||||
wishlist: wishlist,
|
||||
loved: loved,
|
||||
playlists: playlists,
|
||||
favoriteArtists: favoriteArtists,
|
||||
isLoaded: true,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_log.e('Failed to load library collections', error, stack);
|
||||
if (ref.mounted) state = state.copyWith(isLoaded: true);
|
||||
@@ -157,16 +90,39 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final pending = _playlistLoadFutures[playlistId];
|
||||
if (pending != null) return pending;
|
||||
final load = () async {
|
||||
final rows = await _db.loadPlaylistTracks(playlistId);
|
||||
if (!ref.mounted) return;
|
||||
final tracks = rows
|
||||
.map(_parseTrackEntryRow)
|
||||
.whereType<CollectionTrackEntry>()
|
||||
.toList(growable: false);
|
||||
_replacePlaylistById(
|
||||
playlistId,
|
||||
(current) => current.copyWith(tracks: tracks, tracksLoaded: true),
|
||||
);
|
||||
while (ref.mounted) {
|
||||
final loading = state.playlistById(playlistId);
|
||||
if (loading == null || loading.tracksLoaded) return;
|
||||
final snapshot = await _db.loadPlaylistTracksSnapshot(playlistId);
|
||||
late HydratedPlaylistTracks hydrated;
|
||||
try {
|
||||
if (!ref.mounted) return;
|
||||
hydrated = await hydratePlaylistTracksSnapshot(
|
||||
snapshot,
|
||||
knownKeys: loading.trackKeys,
|
||||
);
|
||||
} finally {
|
||||
await cleanupCollectionRows(
|
||||
nativeRowsPath: snapshot.nativeRowsPath,
|
||||
nativeRowsDirectory: snapshot.nativeRowsDirectory,
|
||||
);
|
||||
}
|
||||
if (!ref.mounted) return;
|
||||
final current = state.playlistById(playlistId);
|
||||
if (current == null || current.tracksLoaded) return;
|
||||
// Metadata-only edits share the index. A restore/reload can replace it
|
||||
// while the worker is running; retry rather than publish stale tracks.
|
||||
if (!current.sharesMembershipIndexWith(loading)) continue;
|
||||
_replacePlaylistById(
|
||||
playlistId,
|
||||
(current) => current.withHydratedTracks(
|
||||
tracks: hydrated.tracks,
|
||||
trackKeys: hydrated.trackKeys,
|
||||
membershipUnchanged: hydrated.membershipUnchanged,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}();
|
||||
_playlistLoadFutures[playlistId] = load;
|
||||
try {
|
||||
@@ -182,53 +138,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
await Future.wait(playlistIds.toSet().map(ensurePlaylistLoaded));
|
||||
}
|
||||
|
||||
CollectionTrackEntry? _parseTrackEntryRow(Map<String, dynamic> row) {
|
||||
final key = row['track_key'] as String?;
|
||||
final trackJson = row['track_json'] as String?;
|
||||
if (key == null || key.isEmpty || trackJson == null || trackJson.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(trackJson);
|
||||
if (decoded is! Map) return null;
|
||||
final track = Track.fromJson(Map<String, dynamic>.from(decoded));
|
||||
final addedAtRaw = row['added_at'] as String?;
|
||||
return CollectionTrackEntry(
|
||||
key: key,
|
||||
track: track,
|
||||
addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
CollectionArtistEntry? _parseArtistEntryRow(Map<String, dynamic> row) {
|
||||
final key = row['artist_key'] as String?;
|
||||
final artistJson = row['artist_json'] as String?;
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
artistJson == null ||
|
||||
artistJson.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(artistJson);
|
||||
if (decoded is! Map) return null;
|
||||
final map = Map<String, dynamic>.from(decoded);
|
||||
final addedAtRaw = row['added_at'] as String?;
|
||||
return CollectionArtistEntry.fromJson({
|
||||
...map,
|
||||
'key': key,
|
||||
'addedAt': map['addedAt'] ?? addedAtRaw,
|
||||
});
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _replacePlaylistById(
|
||||
String playlistId,
|
||||
UserPlaylistCollection Function(UserPlaylistCollection playlist) update,
|
||||
@@ -731,7 +640,13 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
Future<Map<String, dynamic>> exportCollections() async {
|
||||
await _ensureLoaded();
|
||||
await ensurePlaylistsLoaded(state.playlists.map((playlist) => playlist.id));
|
||||
return state.toJson();
|
||||
return exportLibraryCollectionsState(state);
|
||||
}
|
||||
|
||||
Future<String> exportCollectionsJson() async {
|
||||
await _ensureLoaded();
|
||||
await ensurePlaylistsLoaded(state.playlists.map((playlist) => playlist.id));
|
||||
return exportLibraryCollectionsJson(state);
|
||||
}
|
||||
|
||||
/// Exports custom playlist cover images as base64, keyed by playlist id.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -31,6 +32,9 @@ class LibraryCollectionsSnapshot {
|
||||
final List<Map<String, dynamic>> playlistRows;
|
||||
final List<Map<String, dynamic>> playlistTrackRows;
|
||||
final List<Map<String, dynamic>> favoriteArtistRows;
|
||||
final String? nativeRowsPath;
|
||||
final String? nativeRowsDirectory;
|
||||
final int? nativeRowCount;
|
||||
|
||||
const LibraryCollectionsSnapshot({
|
||||
required this.wishlistRows,
|
||||
@@ -38,9 +42,28 @@ class LibraryCollectionsSnapshot {
|
||||
required this.playlistRows,
|
||||
required this.playlistTrackRows,
|
||||
required this.favoriteArtistRows,
|
||||
this.nativeRowsPath,
|
||||
this.nativeRowsDirectory,
|
||||
this.nativeRowCount,
|
||||
});
|
||||
}
|
||||
|
||||
class LibraryPlaylistTracksSnapshot {
|
||||
const LibraryPlaylistTracksSnapshot({
|
||||
this.rows = const [],
|
||||
this.nativeRowsPath,
|
||||
this.nativeRowsDirectory,
|
||||
this.nativeRowCount,
|
||||
});
|
||||
final List<Map<String, dynamic>> rows;
|
||||
final String? nativeRowsPath;
|
||||
final String? nativeRowsDirectory;
|
||||
final int? nativeRowCount;
|
||||
}
|
||||
|
||||
typedef NativeCollectionsJobRunner =
|
||||
Future<Map<String, dynamic>> Function(Map<String, dynamic> request);
|
||||
|
||||
class LibraryCollectionsDatabase {
|
||||
static int _restoreAttachmentSequence = 0;
|
||||
static final LibraryCollectionsDatabase instance =
|
||||
@@ -239,60 +262,320 @@ class LibraryCollectionsDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Future<LibraryCollectionsSnapshot> loadSnapshot() async {
|
||||
final db = await database;
|
||||
final wishlistRows = await db.query(
|
||||
_tableWishlist,
|
||||
orderBy: 'added_at DESC, rowid DESC',
|
||||
);
|
||||
final lovedRows = await db.query(
|
||||
_tableLoved,
|
||||
orderBy: 'added_at DESC, rowid DESC',
|
||||
);
|
||||
final playlistRows = await db.rawQuery('''
|
||||
SELECT
|
||||
p.*,
|
||||
Future<LibraryCollectionsSnapshot> loadSnapshot() async =>
|
||||
readDatabaseSnapshot(await database);
|
||||
|
||||
static Future<LibraryCollectionsSnapshot> readDatabaseSnapshot(
|
||||
Database db, {
|
||||
NativeCollectionsJobRunner? nativeJobRunner,
|
||||
Directory? temporaryDirectory,
|
||||
bool forceLegacy = false,
|
||||
}) async {
|
||||
if (!forceLegacy &&
|
||||
(nativeJobRunner != null || PlatformBridge.supportsCoreBackend)) {
|
||||
final probe = await db.rawQuery('''
|
||||
SELECT 1 FROM $_tableWishlist UNION ALL SELECT 1 FROM $_tableLoved
|
||||
UNION ALL SELECT 1 FROM $_tablePlaylists
|
||||
UNION ALL SELECT 1 FROM $_tablePlaylistTracks
|
||||
UNION ALL SELECT 1 FROM $_tableFavoriteArtists LIMIT 64
|
||||
''');
|
||||
if (probe.length == 64) {
|
||||
return _readNativeRows(
|
||||
db,
|
||||
nativeJobRunner: nativeJobRunner,
|
||||
temporaryDirectory: temporaryDirectory,
|
||||
);
|
||||
}
|
||||
}
|
||||
return db.transaction((txn) async {
|
||||
final wishlistRows = await _readDateOrderedRows(
|
||||
txn,
|
||||
_tableWishlist,
|
||||
'added_at',
|
||||
);
|
||||
final lovedRows = await _readDateOrderedRows(
|
||||
txn,
|
||||
_tableLoved,
|
||||
'added_at',
|
||||
);
|
||||
final playlistRows = await _readDateOrderedRows(
|
||||
txn,
|
||||
_tablePlaylists,
|
||||
'created_at',
|
||||
projection:
|
||||
'''p.*,
|
||||
CASE WHEN p.cover_image_path IS NULL OR p.cover_image_path = '' THEN (
|
||||
SELECT pt.track_json
|
||||
FROM $_tablePlaylistTracks pt
|
||||
WHERE pt.playlist_id = p.id
|
||||
ORDER BY pt.added_at ASC, pt.rowid ASC
|
||||
LIMIT 1
|
||||
) END AS preview_track_json
|
||||
FROM $_tablePlaylists p
|
||||
ORDER BY p.created_at DESC, p.rowid DESC
|
||||
''');
|
||||
final playlistTrackRows = await db.query(
|
||||
_tablePlaylistTracks,
|
||||
columns: ['playlist_id', 'track_key'],
|
||||
orderBy: 'playlist_id ASC, rowid ASC',
|
||||
);
|
||||
final favoriteArtistRows = await db.query(
|
||||
_tableFavoriteArtists,
|
||||
orderBy: 'added_at DESC, rowid DESC',
|
||||
);
|
||||
|
||||
return LibraryCollectionsSnapshot(
|
||||
wishlistRows: wishlistRows,
|
||||
lovedRows: lovedRows,
|
||||
playlistRows: playlistRows,
|
||||
playlistTrackRows: playlistTrackRows,
|
||||
favoriteArtistRows: favoriteArtistRows,
|
||||
);
|
||||
) END AS preview_track_json''',
|
||||
alias: 'p',
|
||||
);
|
||||
final playlistTrackRows = <Map<String, dynamic>>[];
|
||||
Object? lastId;
|
||||
int? lastRowId;
|
||||
while (true) {
|
||||
final page = await txn.rawQuery('''
|
||||
SELECT rowid AS __collection_rowid, playlist_id, track_key
|
||||
FROM $_tablePlaylistTracks
|
||||
${lastRowId == null ? '' : 'WHERE playlist_id>? OR (playlist_id=? AND rowid>?)'}
|
||||
ORDER BY playlist_id ASC,rowid ASC LIMIT 256
|
||||
''', lastRowId == null ? const [] : [lastId, lastId, lastRowId]);
|
||||
if (page.isEmpty) break;
|
||||
lastId = page.last['playlist_id'];
|
||||
lastRowId = page.last['__collection_rowid'] as int;
|
||||
playlistTrackRows.addAll(page.map(_withoutRowCursor));
|
||||
if (page.length < 256) break;
|
||||
}
|
||||
final favoriteArtistRows = await _readDateOrderedRows(
|
||||
txn,
|
||||
_tableFavoriteArtists,
|
||||
'added_at',
|
||||
);
|
||||
return LibraryCollectionsSnapshot(
|
||||
wishlistRows: wishlistRows,
|
||||
lovedRows: lovedRows,
|
||||
playlistRows: playlistRows,
|
||||
playlistTrackRows: playlistTrackRows,
|
||||
favoriteArtistRows: favoriteArtistRows,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadPlaylistTracks(
|
||||
String playlistId,
|
||||
) async {
|
||||
final db = await database;
|
||||
return db.query(
|
||||
_tablePlaylistTracks,
|
||||
where: 'playlist_id = ?',
|
||||
whereArgs: [playlistId],
|
||||
orderBy: 'added_at ASC, rowid ASC',
|
||||
return db.transaction(
|
||||
(txn) => _readDateOrderedRows(
|
||||
txn,
|
||||
_tablePlaylistTracks,
|
||||
'added_at',
|
||||
ascending: true,
|
||||
where: 'playlist_id=?',
|
||||
whereArgs: [playlistId],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<LibraryPlaylistTracksSnapshot> loadPlaylistTracksSnapshot(
|
||||
String playlistId,
|
||||
) async => readDatabasePlaylistTracks(await database, playlistId);
|
||||
|
||||
static Future<LibraryPlaylistTracksSnapshot> readDatabasePlaylistTracks(
|
||||
Database db,
|
||||
String playlistId, {
|
||||
NativeCollectionsJobRunner? nativeJobRunner,
|
||||
Directory? temporaryDirectory,
|
||||
bool forceLegacy = false,
|
||||
}) async {
|
||||
if (!forceLegacy &&
|
||||
(nativeJobRunner != null || PlatformBridge.supportsCoreBackend)) {
|
||||
final probe = await db.rawQuery(
|
||||
'SELECT 1 FROM $_tablePlaylistTracks WHERE playlist_id=? LIMIT 64',
|
||||
[playlistId],
|
||||
);
|
||||
if (probe.length == 64) {
|
||||
final snapshot = await _readNativeRows(
|
||||
db,
|
||||
playlistId: playlistId,
|
||||
nativeJobRunner: nativeJobRunner,
|
||||
temporaryDirectory: temporaryDirectory,
|
||||
);
|
||||
return LibraryPlaylistTracksSnapshot(
|
||||
nativeRowsPath: snapshot.nativeRowsPath,
|
||||
nativeRowsDirectory: snapshot.nativeRowsDirectory,
|
||||
nativeRowCount: snapshot.nativeRowCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
final rows = await db.transaction(
|
||||
(txn) => _readDateOrderedRows(
|
||||
txn,
|
||||
_tablePlaylistTracks,
|
||||
'added_at',
|
||||
ascending: true,
|
||||
where: 'playlist_id=?',
|
||||
whereArgs: [playlistId],
|
||||
),
|
||||
);
|
||||
return LibraryPlaylistTracksSnapshot(rows: rows);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _withoutRowCursor(Map<String, Object?> row) =>
|
||||
Map<String, dynamic>.from(row)..remove('__collection_rowid');
|
||||
|
||||
static Future<List<Map<String, dynamic>>> _readDateOrderedRows(
|
||||
DatabaseExecutor db,
|
||||
String table,
|
||||
String dateColumn, {
|
||||
bool ascending = false,
|
||||
String projection = '*',
|
||||
String? alias,
|
||||
String? where,
|
||||
List<Object?> whereArgs = const [],
|
||||
}) async {
|
||||
final rows = <Map<String, dynamic>>[];
|
||||
final qualifier = alias == null ? '' : '$alias.';
|
||||
final comparison = ascending ? '>' : '<';
|
||||
final direction = ascending ? 'ASC' : 'DESC';
|
||||
Object? lastDate;
|
||||
int? lastRowId;
|
||||
while (true) {
|
||||
final predicates = [
|
||||
if (where != null) '($where)',
|
||||
if (lastRowId != null)
|
||||
'($qualifier$dateColumn$comparison? OR ($qualifier$dateColumn=? AND ${qualifier}rowid$comparison?))',
|
||||
];
|
||||
final page = await db.rawQuery(
|
||||
'''
|
||||
SELECT ${qualifier}rowid AS __collection_rowid, $projection
|
||||
FROM $table ${alias ?? ''}
|
||||
${predicates.isEmpty ? '' : 'WHERE ${predicates.join(' AND ')}'}
|
||||
ORDER BY $qualifier$dateColumn $direction,${qualifier}rowid $direction LIMIT 256
|
||||
''',
|
||||
[
|
||||
...whereArgs,
|
||||
if (lastRowId != null) ...[lastDate, lastDate, lastRowId],
|
||||
],
|
||||
);
|
||||
if (page.isEmpty) break;
|
||||
lastDate = page.last[dateColumn];
|
||||
lastRowId = page.last['__collection_rowid'] as int;
|
||||
rows.addAll(page.map(_withoutRowCursor));
|
||||
if (page.length < 256) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
static Future<LibraryCollectionsSnapshot> _readNativeRows(
|
||||
Database db, {
|
||||
String? playlistId,
|
||||
NativeCollectionsJobRunner? nativeJobRunner,
|
||||
Directory? temporaryDirectory,
|
||||
}) async {
|
||||
final temporary =
|
||||
await (temporaryDirectory ?? await getTemporaryDirectory()).createTemp(
|
||||
'collection-read-',
|
||||
);
|
||||
final path = '${temporary.path}/rows.ndjson';
|
||||
try {
|
||||
final snapshotPath = '${temporary.path}/$_dbFileName';
|
||||
await _projectCollectionReadDatabase(
|
||||
db,
|
||||
snapshotPath,
|
||||
playlistId: playlistId,
|
||||
);
|
||||
final request = <String, dynamic>{
|
||||
'operation': 'collections_snapshot',
|
||||
'collections_path': snapshotPath,
|
||||
'output_path': path,
|
||||
'playlist_id': ?playlistId,
|
||||
};
|
||||
final result = await (nativeJobRunner ?? PlatformBridge.runNativeDataJob)(
|
||||
request,
|
||||
);
|
||||
final counts = result['counts'];
|
||||
if (result['published'] != true ||
|
||||
result['rows_path'] != path ||
|
||||
counts is! Map) {
|
||||
throw const FormatException('Invalid native collection snapshot');
|
||||
}
|
||||
var total = 0;
|
||||
for (final count in counts.values) {
|
||||
if (count is! int || count < 0) {
|
||||
throw const FormatException('Invalid native collection row count');
|
||||
}
|
||||
total += count;
|
||||
}
|
||||
return LibraryCollectionsSnapshot(
|
||||
wishlistRows: const [],
|
||||
lovedRows: const [],
|
||||
playlistRows: const [],
|
||||
playlistTrackRows: const [],
|
||||
favoriteArtistRows: const [],
|
||||
nativeRowsPath: path,
|
||||
nativeRowsDirectory: temporary.path,
|
||||
nativeRowCount: total,
|
||||
);
|
||||
} on NativeSqliteSnapshotDetachException {
|
||||
_log.w(
|
||||
'Retaining collection read staging after a failed database detach',
|
||||
);
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
await temporary.delete(recursive: true);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static final _collectionProjectionQueue = Expando<Future<void>>();
|
||||
static var _collectionProjectionSequence = 0;
|
||||
|
||||
/// Both the live database and this projection are owned by the same SQLite
|
||||
/// engine during copying. DETACH closes its private file before Rust opens
|
||||
/// it; independent SQLite copies must never share a live app database.
|
||||
static Future<void> _projectCollectionReadDatabase(
|
||||
Database db,
|
||||
String outputPath, {
|
||||
String? playlistId,
|
||||
}) async {
|
||||
final previous = _collectionProjectionQueue[db];
|
||||
final finished = Completer<void>();
|
||||
_collectionProjectionQueue[db] = finished.future;
|
||||
if (previous != null) await previous;
|
||||
final alias = 'collection_read_${_collectionProjectionSequence++}';
|
||||
var attached = false;
|
||||
try {
|
||||
await db.execute('ATTACH DATABASE ? AS $alias', [outputPath]);
|
||||
attached = true;
|
||||
await db.transaction((txn) async {
|
||||
for (final table in [
|
||||
_tableWishlist,
|
||||
_tableLoved,
|
||||
_tableFavoriteArtists,
|
||||
_tablePlaylists,
|
||||
_tablePlaylistTracks,
|
||||
]) {
|
||||
final where = playlistId == null
|
||||
? ''
|
||||
: table == _tablePlaylists
|
||||
? 'WHERE id=?'
|
||||
: table == _tablePlaylistTracks
|
||||
? 'WHERE playlist_id=?'
|
||||
: 'WHERE 0';
|
||||
// CTAS copies every original column and value. Inserting in original
|
||||
// rowid order preserves all date-tie and membership ordering without
|
||||
// transferring any collection rows through the platform channel.
|
||||
await txn.execute(
|
||||
'CREATE TABLE $alias.$table AS '
|
||||
'SELECT * FROM main.$table $where ORDER BY rowid ASC',
|
||||
where.contains('?') ? [playlistId] : const [],
|
||||
);
|
||||
}
|
||||
await txn.execute('PRAGMA $alias.user_version=$_dbVersion');
|
||||
await txn.execute(
|
||||
'CREATE INDEX $alias.collection_read_members '
|
||||
'ON $_tablePlaylistTracks(playlist_id)',
|
||||
);
|
||||
await txn.execute(
|
||||
'CREATE INDEX $alias.collection_read_preview '
|
||||
'ON $_tablePlaylistTracks(playlist_id,added_at)',
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
if (attached) {
|
||||
await detachNativeSqliteSnapshot(db, alias, outputPath);
|
||||
}
|
||||
} finally {
|
||||
finished.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlaylistPickerSummary>> loadPlaylistPickerSummaries(
|
||||
List<String> requestedTrackKeys,
|
||||
) async => readPlaylistPickerSummaries(await database, requestedTrackKeys);
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:spotiflac_android/models/library_collections.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
|
||||
const _workerRowThreshold = 64;
|
||||
const _workerJsonThreshold = 64 * 1024;
|
||||
|
||||
/// Small collections avoid isolate startup. Large snapshots leave JSON parsing,
|
||||
/// model construction and all membership indices on the worker together.
|
||||
Future<LibraryCollectionsState> hydrateLibraryCollectionsSnapshot(
|
||||
LibraryCollectionsSnapshot snapshot,
|
||||
) async {
|
||||
if (snapshot.nativeRowsPath != null) {
|
||||
try {
|
||||
return await _hydrateRowsInWorker<LibraryCollectionsState>(
|
||||
rows: const {},
|
||||
nativeRowsPath: snapshot.nativeRowsPath,
|
||||
nativeRowCount: snapshot.nativeRowCount,
|
||||
);
|
||||
} finally {
|
||||
await cleanupCollectionRows(
|
||||
nativeRowsPath: snapshot.nativeRowsPath,
|
||||
nativeRowsDirectory: snapshot.nativeRowsDirectory,
|
||||
);
|
||||
}
|
||||
}
|
||||
final rows =
|
||||
snapshot.wishlistRows.length +
|
||||
snapshot.lovedRows.length +
|
||||
snapshot.favoriteArtistRows.length +
|
||||
snapshot.playlistRows.length +
|
||||
snapshot.playlistTrackRows.length;
|
||||
if (rows < _workerRowThreshold &&
|
||||
!_hasLargeJson([
|
||||
...snapshot.wishlistRows,
|
||||
...snapshot.lovedRows,
|
||||
...snapshot.favoriteArtistRows,
|
||||
...snapshot.playlistRows,
|
||||
])) {
|
||||
return decodeLibraryCollectionsSnapshot(snapshot);
|
||||
}
|
||||
return _hydrateRowsInWorker<LibraryCollectionsState>(
|
||||
rows: {
|
||||
_CollectionRows.wishlist: snapshot.wishlistRows,
|
||||
_CollectionRows.loved: snapshot.lovedRows,
|
||||
_CollectionRows.artists: snapshot.favoriteArtistRows,
|
||||
_CollectionRows.membership: snapshot.playlistTrackRows,
|
||||
_CollectionRows.playlists: snapshot.playlistRows,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
LibraryCollectionsState decodeLibraryCollectionsSnapshot(
|
||||
LibraryCollectionsSnapshot snapshot,
|
||||
) {
|
||||
final accumulator = _CollectionHydrationAccumulator()
|
||||
..add(_CollectionRows.wishlist, snapshot.wishlistRows)
|
||||
..add(_CollectionRows.loved, snapshot.lovedRows)
|
||||
..add(_CollectionRows.artists, snapshot.favoriteArtistRows)
|
||||
..add(_CollectionRows.membership, snapshot.playlistTrackRows)
|
||||
..add(_CollectionRows.playlists, snapshot.playlistRows);
|
||||
return accumulator.finish();
|
||||
}
|
||||
|
||||
List<UserPlaylistCollection> _decodePlaylistRows(
|
||||
List<Map<String, dynamic>> rows,
|
||||
Map<String, Set<String>> trackKeysByPlaylist,
|
||||
) {
|
||||
final playlists = <UserPlaylistCollection>[];
|
||||
for (final row in rows) {
|
||||
final id = row['id'] as String?;
|
||||
if (id == null || id.isEmpty) continue;
|
||||
final createdAtRaw = row['created_at'] as String?;
|
||||
final updatedAtRaw = row['updated_at'] as String?;
|
||||
final createdAt = DateTime.tryParse(createdAtRaw ?? '') ?? DateTime.now();
|
||||
final updatedAt = DateTime.tryParse(updatedAtRaw ?? '') ?? createdAt;
|
||||
String? previewCover;
|
||||
final previewTrackJson = row['preview_track_json'] as String?;
|
||||
if (previewTrackJson != null && previewTrackJson.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(previewTrackJson);
|
||||
if (decoded is Map) previewCover = decoded['coverUrl']?.toString();
|
||||
} catch (_) {}
|
||||
}
|
||||
playlists.add(
|
||||
UserPlaylistCollection(
|
||||
id: id,
|
||||
name: row['name'] as String? ?? '',
|
||||
coverImagePath: row['cover_image_path'] as String?,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
tracks: const [],
|
||||
previewCover: previewCover,
|
||||
tracksLoaded: false,
|
||||
trackKeys: trackKeysByPlaylist[id],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
class HydratedPlaylistTracks {
|
||||
const HydratedPlaylistTracks({
|
||||
required this.tracks,
|
||||
required this.trackKeys,
|
||||
required this.membershipUnchanged,
|
||||
});
|
||||
|
||||
final List<CollectionTrackEntry> tracks;
|
||||
final Set<String> trackKeys;
|
||||
final bool membershipUnchanged;
|
||||
}
|
||||
|
||||
typedef _PlaylistHydrationRequest = ({
|
||||
List<Map<String, dynamic>> rows,
|
||||
Set<String> knownKeys,
|
||||
});
|
||||
|
||||
Future<HydratedPlaylistTracks> hydratePlaylistTracks(
|
||||
List<Map<String, dynamic>> rows, {
|
||||
required Set<String> knownKeys,
|
||||
}) async {
|
||||
final request = (rows: rows, knownKeys: knownKeys);
|
||||
if (rows.length < _workerRowThreshold && !_hasLargeJson(rows)) {
|
||||
return _decodePlaylistTracks(request);
|
||||
}
|
||||
return _hydrateRowsInWorker<HydratedPlaylistTracks>(
|
||||
rows: {_CollectionRows.wishlist: rows},
|
||||
knownKeys: knownKeys,
|
||||
);
|
||||
}
|
||||
|
||||
Future<HydratedPlaylistTracks> hydratePlaylistTracksSnapshot(
|
||||
LibraryPlaylistTracksSnapshot snapshot, {
|
||||
required Set<String> knownKeys,
|
||||
}) async {
|
||||
if (snapshot.nativeRowsPath == null) {
|
||||
return hydratePlaylistTracks(snapshot.rows, knownKeys: knownKeys);
|
||||
}
|
||||
try {
|
||||
return await _hydrateRowsInWorker<HydratedPlaylistTracks>(
|
||||
rows: const {},
|
||||
knownKeys: knownKeys,
|
||||
nativeRowsPath: snapshot.nativeRowsPath,
|
||||
nativeRowCount: snapshot.nativeRowCount,
|
||||
);
|
||||
} finally {
|
||||
await cleanupCollectionRows(
|
||||
nativeRowsPath: snapshot.nativeRowsPath,
|
||||
nativeRowsDirectory: snapshot.nativeRowsDirectory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cleanupCollectionRows({
|
||||
String? nativeRowsPath,
|
||||
String? nativeRowsDirectory,
|
||||
}) async {
|
||||
try {
|
||||
if (nativeRowsDirectory != null) {
|
||||
await Directory(nativeRowsDirectory).delete(recursive: true);
|
||||
} else if (nativeRowsPath != null) {
|
||||
await File(nativeRowsPath).delete();
|
||||
}
|
||||
} on FileSystemException {
|
||||
// The consuming worker may already have removed its row file.
|
||||
}
|
||||
}
|
||||
|
||||
HydratedPlaylistTracks _decodePlaylistTracks(
|
||||
_PlaylistHydrationRequest request,
|
||||
) {
|
||||
final tracks = _decodeTrackRows(request.rows).toList(growable: false);
|
||||
final keys = tracks.map((entry) => entry.key).toSet();
|
||||
return HydratedPlaylistTracks(
|
||||
tracks: tracks,
|
||||
trackKeys: keys,
|
||||
membershipUnchanged:
|
||||
keys.length == request.knownKeys.length &&
|
||||
keys.every(request.knownKeys.contains),
|
||||
);
|
||||
}
|
||||
|
||||
List<CollectionTrackEntry> _decodeTrackRows(List<Map<String, dynamic>> rows) =>
|
||||
[for (final row in rows) ?_parseTrackRow(row)];
|
||||
|
||||
CollectionTrackEntry? _parseTrackRow(Map<String, dynamic> row) {
|
||||
final key = row['track_key'] as String?;
|
||||
final trackJson = row['track_json'] as String?;
|
||||
if (key == null || key.isEmpty || trackJson == null || trackJson.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(trackJson);
|
||||
if (decoded is! Map) return null;
|
||||
return CollectionTrackEntry(
|
||||
key: key,
|
||||
track: Track.fromJson(Map<String, dynamic>.from(decoded)),
|
||||
addedAt:
|
||||
DateTime.tryParse(row['added_at'] as String? ?? '') ?? DateTime.now(),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
CollectionArtistEntry? _parseArtistRow(Map<String, dynamic> row) {
|
||||
final key = row['artist_key'] as String?;
|
||||
final artistJson = row['artist_json'] as String?;
|
||||
if (key == null || key.isEmpty || artistJson == null || artistJson.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(artistJson);
|
||||
if (decoded is! Map) return null;
|
||||
final map = Map<String, dynamic>.from(decoded);
|
||||
return CollectionArtistEntry.fromJson({
|
||||
...map,
|
||||
'key': key,
|
||||
'addedAt': map['addedAt'] ?? row['added_at'],
|
||||
});
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasLargeJson(List<Map<String, dynamic>> rows) {
|
||||
var length = 0;
|
||||
for (final row in rows) {
|
||||
for (final column in ['track_json', 'artist_json', 'preview_track_json']) {
|
||||
final value = row[column];
|
||||
if (value is String) length += value.length;
|
||||
}
|
||||
if (length >= _workerJsonThreshold) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
enum _CollectionRows { wishlist, loved, artists, membership, playlists }
|
||||
|
||||
class _CollectionHydrationAccumulator {
|
||||
final wishlist = <CollectionTrackEntry>[];
|
||||
final loved = <CollectionTrackEntry>[];
|
||||
final artists = <CollectionArtistEntry>[];
|
||||
final playlistRows = <Map<String, dynamic>>[];
|
||||
final trackKeysByPlaylist = <String, Set<String>>{};
|
||||
|
||||
void add(_CollectionRows kind, List<Map<String, dynamic>> rows) {
|
||||
switch (kind) {
|
||||
case _CollectionRows.wishlist:
|
||||
wishlist.addAll(_decodeTrackRows(rows));
|
||||
case _CollectionRows.loved:
|
||||
loved.addAll(_decodeTrackRows(rows));
|
||||
case _CollectionRows.artists:
|
||||
for (final row in rows) {
|
||||
final artist = _parseArtistRow(row);
|
||||
if (artist != null) artists.add(artist);
|
||||
}
|
||||
case _CollectionRows.playlists:
|
||||
playlistRows.addAll(rows);
|
||||
case _CollectionRows.membership:
|
||||
for (final row in rows) {
|
||||
final id = row['playlist_id'] as String?;
|
||||
final key = row['track_key'] as String?;
|
||||
if (id == null || id.isEmpty || key == null || key.isEmpty) continue;
|
||||
trackKeysByPlaylist.putIfAbsent(id, () => {}).add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryCollectionsState finish() => LibraryCollectionsState(
|
||||
wishlist: wishlist,
|
||||
loved: loved,
|
||||
favoriteArtists: artists,
|
||||
playlists: _decodePlaylistRows(playlistRows, trackKeysByPlaylist),
|
||||
isLoaded: true,
|
||||
);
|
||||
}
|
||||
|
||||
enum _CollectionWorkerMode { snapshot, playlist, export, exportJson }
|
||||
|
||||
typedef _CollectionWorkerRequest = ({
|
||||
SendPort result,
|
||||
_CollectionWorkerMode mode,
|
||||
});
|
||||
typedef _CollectionRowBatch = ({
|
||||
_CollectionRows kind,
|
||||
List<Map<String, dynamic>> rows,
|
||||
});
|
||||
|
||||
typedef _NativeCollectionRows = ({String path, int? count});
|
||||
|
||||
/// A single giant compute argument copies every row map before the isolate
|
||||
/// starts, still blocking the caller. Stream bounded batches instead. The
|
||||
/// worker consumes JSON as it arrives; Isolate.exit transfers the final model
|
||||
/// graph without another full copy on the UI isolate.
|
||||
Future<T> _hydrateRowsInWorker<T>({
|
||||
required Map<_CollectionRows, List<Map<String, dynamic>>> rows,
|
||||
Set<String>? knownKeys,
|
||||
String? nativeRowsPath,
|
||||
int? nativeRowCount,
|
||||
}) => _runCollectionWorker<T>(
|
||||
mode: knownKeys == null
|
||||
? _CollectionWorkerMode.snapshot
|
||||
: _CollectionWorkerMode.playlist,
|
||||
feed: (sendBatch) async {
|
||||
for (final entry in rows.entries) {
|
||||
for (var offset = 0; offset < entry.value.length; offset += 256) {
|
||||
final end = (offset + 256).clamp(0, entry.value.length);
|
||||
await sendBatch((
|
||||
kind: entry.key,
|
||||
rows: entry.value.sublist(offset, end),
|
||||
));
|
||||
}
|
||||
}
|
||||
if (knownKeys != null) {
|
||||
var batch = <String>[];
|
||||
for (final key in knownKeys) {
|
||||
batch.add(key);
|
||||
if (batch.length < 256) continue;
|
||||
await sendBatch(batch);
|
||||
batch = <String>[];
|
||||
}
|
||||
if (batch.isNotEmpty) await sendBatch(batch);
|
||||
}
|
||||
if (nativeRowsPath != null) {
|
||||
await sendBatch((path: nativeRowsPath, count: nativeRowCount));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
typedef _SendCollectionBatch = Future<void> Function(Object batch);
|
||||
|
||||
Future<T> _runCollectionWorker<T>({
|
||||
required _CollectionWorkerMode mode,
|
||||
required Future<void> Function(_SendCollectionBatch sendBatch) feed,
|
||||
}) async {
|
||||
final replies = ReceivePort();
|
||||
final ready = Completer<SendPort>();
|
||||
final result = Completer<T>();
|
||||
Completer<void>? acknowledgement;
|
||||
// An error can arrive while batches are being sent, before the final await.
|
||||
result.future.ignore();
|
||||
final subscription = replies.listen((Object? message) {
|
||||
if (message is SendPort) {
|
||||
ready.complete(message);
|
||||
} else if (message == true) {
|
||||
acknowledgement?.complete();
|
||||
} else if (message is List<Object?> && message.length == 1) {
|
||||
result.complete(message.single as T);
|
||||
} else {
|
||||
final error = message is List<Object?> && message.length == 2
|
||||
? RemoteError(message[0].toString(), message[1].toString())
|
||||
: StateError('Collections hydration worker exited without a result');
|
||||
if (!ready.isCompleted) ready.completeError(error);
|
||||
if (!result.isCompleted) result.completeError(error);
|
||||
if (acknowledgement case final pending? when !pending.isCompleted) {
|
||||
pending.completeError(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
Isolate? isolate;
|
||||
try {
|
||||
isolate = await Isolate.spawn(
|
||||
_collectionHydrationWorker,
|
||||
(result: replies.sendPort, mode: mode),
|
||||
onError: replies.sendPort,
|
||||
onExit: replies.sendPort,
|
||||
debugName: 'library collections hydration',
|
||||
);
|
||||
final commands = await ready.future;
|
||||
Future<void> sendBatch(Object batch) async {
|
||||
final received = Completer<void>();
|
||||
acknowledgement = received;
|
||||
commands.send(batch);
|
||||
await received.future;
|
||||
}
|
||||
|
||||
await feed(sendBatch);
|
||||
commands.send(null);
|
||||
return await result.future;
|
||||
} finally {
|
||||
isolate?.kill(priority: Isolate.immediate);
|
||||
await subscription.cancel();
|
||||
replies.close();
|
||||
}
|
||||
}
|
||||
|
||||
void _collectionHydrationWorker(_CollectionWorkerRequest request) async {
|
||||
final commands = ReceivePort();
|
||||
final accumulator = _CollectionHydrationAccumulator();
|
||||
final exporter = _CollectionExportAccumulator();
|
||||
final knownKeys = <String>{};
|
||||
request.result.send(commands.sendPort);
|
||||
await for (final Object? message in commands) {
|
||||
if (message case _CollectionRowBatch batch) {
|
||||
accumulator.add(batch.kind, batch.rows);
|
||||
request.result.send(true);
|
||||
} else if (message is List<String>) {
|
||||
knownKeys.addAll(message);
|
||||
request.result.send(true);
|
||||
} else if (message case _NativeCollectionRows input) {
|
||||
await _readNativeCollectionRows(input, accumulator);
|
||||
request.result.send(true);
|
||||
} else if (message case _CollectionExportBatch batch) {
|
||||
exporter.add(batch);
|
||||
request.result.send(true);
|
||||
} else if (message case _PlaylistExportHeader header) {
|
||||
exporter.startPlaylist(header);
|
||||
request.result.send(true);
|
||||
} else if (message == null) {
|
||||
commands.close();
|
||||
if (request.mode == _CollectionWorkerMode.export ||
|
||||
request.mode == _CollectionWorkerMode.exportJson) {
|
||||
final exported = exporter.finish();
|
||||
Isolate.exit(request.result, [
|
||||
request.mode == _CollectionWorkerMode.exportJson
|
||||
? jsonEncode(exported)
|
||||
: exported,
|
||||
]);
|
||||
}
|
||||
if (request.mode == _CollectionWorkerMode.snapshot) {
|
||||
Isolate.exit(request.result, [accumulator.finish()]);
|
||||
}
|
||||
final tracks = accumulator.wishlist.toList(growable: false);
|
||||
final keys = tracks.map((entry) => entry.key).toSet();
|
||||
Isolate.exit(request.result, [
|
||||
HydratedPlaylistTracks(
|
||||
tracks: tracks,
|
||||
trackKeys: keys,
|
||||
membershipUnchanged:
|
||||
keys.length == knownKeys.length && keys.every(knownKeys.contains),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _readNativeCollectionRows(
|
||||
_NativeCollectionRows input,
|
||||
_CollectionHydrationAccumulator accumulator,
|
||||
) async {
|
||||
var count = 0;
|
||||
_CollectionRows? previousKind;
|
||||
var batch = <Map<String, dynamic>>[];
|
||||
try {
|
||||
await for (final line in File(
|
||||
input.path,
|
||||
).openRead().transform(utf8.decoder).transform(const LineSplitter())) {
|
||||
final decoded = jsonDecode(line) as Map<String, dynamic>;
|
||||
final kind = switch (decoded['kind']) {
|
||||
'wishlist' || 'playlist_track' => _CollectionRows.wishlist,
|
||||
'loved' => _CollectionRows.loved,
|
||||
'artist' => _CollectionRows.artists,
|
||||
'membership' => _CollectionRows.membership,
|
||||
'playlist' => _CollectionRows.playlists,
|
||||
_ => throw const FormatException(
|
||||
'Unknown collection snapshot row kind',
|
||||
),
|
||||
};
|
||||
if (previousKind != null &&
|
||||
(kind != previousKind || batch.length == 256)) {
|
||||
accumulator.add(previousKind, batch);
|
||||
batch = <Map<String, dynamic>>[];
|
||||
}
|
||||
previousKind = kind;
|
||||
batch.add(Map<String, dynamic>.from(decoded['row'] as Map));
|
||||
count++;
|
||||
}
|
||||
if (previousKind != null && batch.isNotEmpty) {
|
||||
accumulator.add(previousKind, batch);
|
||||
}
|
||||
if (input.count != null && count != input.count) {
|
||||
throw const FormatException('Incomplete native collection snapshot');
|
||||
}
|
||||
} finally {
|
||||
await File(input.path).delete();
|
||||
}
|
||||
}
|
||||
|
||||
enum _CollectionExportRows { wishlist, loved, artist, playlistTrack }
|
||||
|
||||
typedef _CollectionExportBatch = ({
|
||||
_CollectionExportRows kind,
|
||||
List<Object> entries,
|
||||
});
|
||||
typedef _PlaylistExportHeader = ({
|
||||
String id,
|
||||
String name,
|
||||
String? cover,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
});
|
||||
|
||||
/// Converts the stable published model snapshot on a worker without cloning
|
||||
/// the whole graph in a single send. Playlist headers exclude membership sets
|
||||
/// and their tracks are submitted separately in bounded ordered batches.
|
||||
Future<Map<String, dynamic>> exportLibraryCollectionsState(
|
||||
LibraryCollectionsState state,
|
||||
) async {
|
||||
var count =
|
||||
state.wishlistCount +
|
||||
state.lovedCount +
|
||||
state.favoriteArtistCount +
|
||||
state.playlistCount;
|
||||
for (final playlist in state.playlists) {
|
||||
if (count >= _workerRowThreshold) break;
|
||||
count += playlist.tracks.length;
|
||||
}
|
||||
if (count < _workerRowThreshold) return state.toJson();
|
||||
return _exportLibraryCollectionsInWorker(state, _CollectionWorkerMode.export);
|
||||
}
|
||||
|
||||
/// Backup writers can embed this trusted JSON fragment directly, avoiding a
|
||||
/// second transfer of the large exported map from the UI to another worker.
|
||||
Future<String> exportLibraryCollectionsJson(LibraryCollectionsState state) =>
|
||||
_exportLibraryCollectionsInWorker(state, _CollectionWorkerMode.exportJson);
|
||||
|
||||
Future<T> _exportLibraryCollectionsInWorker<T>(
|
||||
LibraryCollectionsState state,
|
||||
_CollectionWorkerMode mode,
|
||||
) => _runCollectionWorker<T>(
|
||||
mode: mode,
|
||||
feed: (sendBatch) async {
|
||||
Future<void> entries(
|
||||
_CollectionExportRows kind,
|
||||
List<Object> values,
|
||||
) async {
|
||||
for (var offset = 0; offset < values.length; offset += 256) {
|
||||
await sendBatch((
|
||||
kind: kind,
|
||||
entries: values.sublist(
|
||||
offset,
|
||||
(offset + 256).clamp(0, values.length),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
await entries(_CollectionExportRows.wishlist, state.wishlist);
|
||||
await entries(_CollectionExportRows.loved, state.loved);
|
||||
for (final playlist in state.playlists) {
|
||||
await sendBatch((
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
cover: playlist.coverImagePath,
|
||||
createdAt: playlist.createdAt,
|
||||
updatedAt: playlist.updatedAt,
|
||||
));
|
||||
await entries(_CollectionExportRows.playlistTrack, playlist.tracks);
|
||||
}
|
||||
await entries(_CollectionExportRows.artist, state.favoriteArtists);
|
||||
},
|
||||
);
|
||||
|
||||
class _CollectionExportAccumulator {
|
||||
final wishlist = <Map<String, dynamic>>[];
|
||||
final loved = <Map<String, dynamic>>[];
|
||||
final artists = <Map<String, dynamic>>[];
|
||||
final playlists = <Map<String, dynamic>>[];
|
||||
List<Map<String, dynamic>>? playlistTracks;
|
||||
|
||||
void startPlaylist(_PlaylistExportHeader header) {
|
||||
playlistTracks = <Map<String, dynamic>>[];
|
||||
playlists.add({
|
||||
'id': header.id,
|
||||
'name': header.name,
|
||||
if (header.cover != null) 'coverImagePath': header.cover,
|
||||
'createdAt': header.createdAt.toIso8601String(),
|
||||
'updatedAt': header.updatedAt.toIso8601String(),
|
||||
'tracks': playlistTracks,
|
||||
});
|
||||
}
|
||||
|
||||
void add(_CollectionExportBatch batch) {
|
||||
if (batch.kind == _CollectionExportRows.artist) {
|
||||
artists.addAll(
|
||||
batch.entries.map((entry) => (entry as CollectionArtistEntry).toJson()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final target = switch (batch.kind) {
|
||||
_CollectionExportRows.wishlist => wishlist,
|
||||
_CollectionExportRows.loved => loved,
|
||||
_CollectionExportRows.playlistTrack =>
|
||||
playlistTracks ??
|
||||
(throw const FormatException('Playlist tracks require a header')),
|
||||
_CollectionExportRows.artist => throw StateError(
|
||||
'Artist target is handled above',
|
||||
),
|
||||
};
|
||||
target.addAll(
|
||||
batch.entries.map((entry) => (entry as CollectionTrackEntry).toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> finish() => {
|
||||
'wishlist': wishlist,
|
||||
'loved': loved,
|
||||
'playlists': playlists,
|
||||
'favoriteArtists': artists,
|
||||
};
|
||||
}
|
||||
@@ -125,6 +125,7 @@ fn execute(
|
||||
request,
|
||||
check,
|
||||
),
|
||||
"collections_snapshot" => crate::native_collections::execute(request, check),
|
||||
operation if operation.starts_with("library_") => {
|
||||
crate::native_library::execute(request, check)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ mod manager;
|
||||
mod metadata;
|
||||
mod native_backup;
|
||||
mod native_cache;
|
||||
mod native_collections;
|
||||
mod native_id3;
|
||||
mod native_library;
|
||||
mod native_listing;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Collection rows stay on the IO worker until a bounded Dart hydration worker
|
||||
//! consumes their NDJSON file. The bridge returns only a path and row counts.
|
||||
use rusqlite::{Connection, OpenFlags, Transaction, types::ValueRef};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::{
|
||||
io::{BufWriter, Write},
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
type Check<'a> = &'a dyn Fn() -> Result<(), String>;
|
||||
|
||||
const SNAPSHOT_QUERIES: &[(&str, &str)] = &[
|
||||
(
|
||||
"wishlist",
|
||||
"SELECT * FROM wishlist_tracks ORDER BY added_at DESC,rowid DESC",
|
||||
),
|
||||
(
|
||||
"loved",
|
||||
"SELECT * FROM loved_tracks ORDER BY added_at DESC,rowid DESC",
|
||||
),
|
||||
(
|
||||
"playlist",
|
||||
"SELECT p.*, CASE WHEN p.cover_image_path IS NULL OR p.cover_image_path='' THEN (
|
||||
SELECT pt.track_json FROM playlist_tracks pt WHERE pt.playlist_id=p.id
|
||||
ORDER BY pt.added_at ASC,pt.rowid ASC LIMIT 1
|
||||
) END AS preview_track_json FROM playlists p ORDER BY p.created_at DESC,p.rowid DESC",
|
||||
),
|
||||
(
|
||||
"membership",
|
||||
"SELECT playlist_id,track_key FROM playlist_tracks ORDER BY playlist_id ASC,rowid ASC",
|
||||
),
|
||||
(
|
||||
"artist",
|
||||
"SELECT * FROM favorite_artists ORDER BY added_at DESC,rowid DESC",
|
||||
),
|
||||
];
|
||||
|
||||
fn required<'a>(request: &'a Value, field: &str) -> Result<&'a str, String> {
|
||||
request[field]
|
||||
.as_str()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| format!("Collection snapshot requires {field}"))
|
||||
}
|
||||
|
||||
fn write_rows(
|
||||
transaction: &Transaction<'_>,
|
||||
writer: &mut impl Write,
|
||||
kind: &str,
|
||||
query: &str,
|
||||
playlist_id: Option<&str>,
|
||||
check: Check<'_>,
|
||||
) -> Result<u64, String> {
|
||||
check()?;
|
||||
let mut statement = transaction.prepare(query).map_err(|e| e.to_string())?;
|
||||
let columns = statement
|
||||
.column_names()
|
||||
.iter()
|
||||
.map(|name| (*name).to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let mut rows = if let Some(id) = playlist_id {
|
||||
statement.query([id])
|
||||
} else {
|
||||
statement.query([])
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut count = 0;
|
||||
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||
check()?;
|
||||
let mut values = Map::new();
|
||||
for (index, name) in columns.iter().enumerate() {
|
||||
let value = match row.get_ref(index).map_err(|e| e.to_string())? {
|
||||
ValueRef::Null => Value::Null,
|
||||
ValueRef::Integer(value) => Value::from(value),
|
||||
ValueRef::Real(value) => Value::from(value),
|
||||
ValueRef::Text(value) => {
|
||||
Value::from(std::str::from_utf8(value).map_err(|e| e.to_string())?)
|
||||
}
|
||||
ValueRef::Blob(value) => {
|
||||
Value::Array(value.iter().copied().map(Value::from).collect())
|
||||
}
|
||||
};
|
||||
values.insert(name.clone(), value);
|
||||
}
|
||||
serde_json::to_writer(&mut *writer, &json!({"kind":kind,"row":values}))
|
||||
.map_err(|e| format!("write collection snapshot: {e}"))?;
|
||||
writer.write_all(b"\n").map_err(|e| e.to_string())?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub(crate) fn execute(request: &Value, check: Check<'_>) -> Result<Value, String> {
|
||||
check()?;
|
||||
if required(request, "operation")? != "collections_snapshot" {
|
||||
return Err("Unknown native collection operation".into());
|
||||
}
|
||||
let database_path = Path::new(required(request, "collections_path")?);
|
||||
if !database_path.is_absolute()
|
||||
|| database_path.file_name().and_then(|name| name.to_str())
|
||||
!= Some("library_collections.db")
|
||||
{
|
||||
return Err("Collection snapshot requires existing library_collections.db".into());
|
||||
}
|
||||
let output_path = Path::new(required(request, "output_path")?);
|
||||
if !output_path.is_absolute() {
|
||||
return Err("Collection snapshot output path must be absolute".into());
|
||||
}
|
||||
let mut db = Connection::open_with_flags(database_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| format!("open collection snapshot: {e}"))?;
|
||||
db.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let transaction = db.transaction().map_err(|e| e.to_string())?;
|
||||
let version: i64 = transaction
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if version != 2 {
|
||||
return Err(format!(
|
||||
"Collection schema v{version} is incompatible with native v2"
|
||||
));
|
||||
}
|
||||
let mut staged = tempfile::NamedTempFile::new_in(
|
||||
output_path
|
||||
.parent()
|
||||
.ok_or("Collection snapshot requires output parent")?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut counts = Map::new();
|
||||
{
|
||||
let mut writer = BufWriter::with_capacity(64 * 1024, staged.as_file_mut());
|
||||
if let Some(id) = request.get("playlist_id") {
|
||||
let id = id
|
||||
.as_str()
|
||||
.ok_or("Collection playlist_id must be a string")?;
|
||||
counts.insert(
|
||||
"playlist_track".into(),
|
||||
Value::from(write_rows(
|
||||
&transaction,
|
||||
&mut writer,
|
||||
"playlist_track",
|
||||
"SELECT * FROM playlist_tracks WHERE playlist_id=?
|
||||
ORDER BY added_at ASC,rowid ASC",
|
||||
Some(id),
|
||||
check,
|
||||
)?),
|
||||
);
|
||||
} else {
|
||||
for (kind, sql) in SNAPSHOT_QUERIES {
|
||||
counts.insert(
|
||||
(*kind).into(),
|
||||
Value::from(write_rows(
|
||||
&transaction,
|
||||
&mut writer,
|
||||
kind,
|
||||
sql,
|
||||
None,
|
||||
check,
|
||||
)?),
|
||||
);
|
||||
}
|
||||
}
|
||||
writer.flush().map_err(|e| e.to_string())?;
|
||||
}
|
||||
transaction.commit().map_err(|e| e.to_string())?;
|
||||
check()?;
|
||||
// Never replace an existing result. RAII removes partial files on SQL, IO,
|
||||
// schema or cancellation errors before this atomic publication.
|
||||
staged
|
||||
.persist_noclobber(output_path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({"rows_path":output_path,"counts":counts,"published":true,"committed":true}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,220 @@
|
||||
use super::*;
|
||||
use std::{cell::Cell, fs};
|
||||
|
||||
struct Fixture {
|
||||
directory: tempfile::TempDir,
|
||||
db: Connection,
|
||||
request: Value,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("library_collections.db");
|
||||
let db = Connection::open(&path).unwrap();
|
||||
db.execute_batch(r#"PRAGMA user_version=2; PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE wishlist_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL);
|
||||
CREATE TABLE loved_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL);
|
||||
CREATE TABLE favorite_artists(artist_key TEXT PRIMARY KEY,artist_json TEXT NOT NULL,added_at TEXT NOT NULL);
|
||||
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);
|
||||
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));
|
||||
INSERT INTO wishlist_tracks VALUES('first','{"id":"a"}','date');
|
||||
INSERT INTO wishlist_tracks VALUES('second','corrupt json','date');
|
||||
INSERT INTO loved_tracks VALUES('loved','[]','date');
|
||||
INSERT INTO favorite_artists VALUES('artist','{}','date');
|
||||
INSERT INTO playlists VALUES('p','音楽 🎵',NULL,'date','date');
|
||||
INSERT INTO playlists VALUES('custom','İstanbul','/cover.jpg','date','date');
|
||||
INSERT INTO playlists VALUES('empty','Empty','', 'date','date');
|
||||
INSERT INTO playlist_tracks VALUES('p','second','{"coverUrl":"first.jpg"}','date');
|
||||
INSERT INTO playlist_tracks VALUES('p','first','{"coverUrl":"second.jpg"}','date');
|
||||
INSERT INTO playlist_tracks VALUES('custom','custom','{}','date');"#).unwrap();
|
||||
let request = json!({"operation":"collections_snapshot","collections_path":path,
|
||||
"output_path":directory.path().join("snapshot.ndjson")});
|
||||
Self {
|
||||
directory,
|
||||
db,
|
||||
request,
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(&self) -> Vec<Value> {
|
||||
fs::read_to_string(self.request["output_path"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn staged_files(&self) -> Vec<String> {
|
||||
fs::read_dir(self.directory.path())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().to_str().unwrap().to_owned())
|
||||
.filter(|name| !name.starts_with("library_collections.db"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_preserves_nulls_previews_unicode_and_all_five_query_orders() {
|
||||
let fixture = Fixture::new();
|
||||
let result = execute(&fixture.request, &|| Ok(())).unwrap();
|
||||
assert_eq!(
|
||||
result["counts"],
|
||||
json!({"wishlist":2,"loved":1,"playlist":3,
|
||||
"membership":3,"artist":1})
|
||||
);
|
||||
assert_eq!(result["published"], true);
|
||||
assert_eq!(result["committed"], true);
|
||||
let rows = fixture.rows();
|
||||
assert_eq!(rows[0]["row"]["track_key"], "second");
|
||||
assert_eq!(rows[1]["row"]["track_key"], "first");
|
||||
let playlists = rows
|
||||
.iter()
|
||||
.filter(|row| row["kind"] == "playlist")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
playlists
|
||||
.iter()
|
||||
.map(|row| row["row"]["id"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["empty", "custom", "p"]
|
||||
);
|
||||
assert_eq!(playlists[0]["row"]["preview_track_json"], Value::Null);
|
||||
assert_eq!(playlists[1]["row"]["preview_track_json"], Value::Null);
|
||||
assert_eq!(
|
||||
playlists[2]["row"]["preview_track_json"],
|
||||
"{\"coverUrl\":\"first.jpg\"}"
|
||||
);
|
||||
assert_eq!(playlists[2]["row"]["name"], "音楽 🎵");
|
||||
let members = rows
|
||||
.iter()
|
||||
.filter(|row| row["kind"] == "membership")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
members
|
||||
.iter()
|
||||
.map(|row| row["row"]["track_key"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["custom", "second", "first"]
|
||||
);
|
||||
assert_eq!(members[0]["row"].as_object().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playlist_projection_is_bound_and_retains_ascending_date_rowid_order() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.request["playlist_id"] = Value::from("p");
|
||||
let result = execute(&fixture.request, &|| Ok(())).unwrap();
|
||||
assert_eq!(result["counts"], json!({"playlist_track":2}));
|
||||
assert_eq!(
|
||||
fixture
|
||||
.rows()
|
||||
.iter()
|
||||
.map(|row| row["row"]["track_key"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["second", "first"]
|
||||
);
|
||||
let mut missing = Fixture::new();
|
||||
missing.request["playlist_id"] = Value::from("p' OR 1=1 --");
|
||||
assert_eq!(
|
||||
execute(&missing.request, &|| Ok(())).unwrap()["counts"],
|
||||
json!({"playlist_track":0})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_every_checkpoint_removes_staging_and_preserves_database() {
|
||||
let fixture = Fixture::new();
|
||||
let calls = Cell::new(0);
|
||||
execute(&fixture.request, &|| {
|
||||
calls.set(calls.get() + 1);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for cancel_at in 1..=calls.get() {
|
||||
let fixture = Fixture::new();
|
||||
let current = Cell::new(0);
|
||||
let result = execute(&fixture.request, &|| {
|
||||
current.set(current.get() + 1);
|
||||
if current.get() == cancel_at {
|
||||
Err("cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
assert!(result.is_err(), "checkpoint {cancel_at}");
|
||||
assert!(fixture.staged_files().is_empty(), "checkpoint {cancel_at}");
|
||||
assert_eq!(
|
||||
fixture
|
||||
.db
|
||||
.query_row("SELECT COUNT(*) FROM wishlist_tracks", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_missing_database_and_existing_output_never_publish_or_overwrite() {
|
||||
for kind in 0..3 {
|
||||
let mut fixture = Fixture::new();
|
||||
match kind {
|
||||
0 => fixture.db.execute_batch("PRAGMA user_version=3").unwrap(),
|
||||
1 => {
|
||||
fixture.request["collections_path"] = Value::from(
|
||||
fixture
|
||||
.directory
|
||||
.path()
|
||||
.join("missing/library_collections.db")
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
_ => fs::write(fixture.request["output_path"].as_str().unwrap(), "kept").unwrap(),
|
||||
}
|
||||
assert!(execute(&fixture.request, &|| Ok(())).is_err());
|
||||
if kind == 2 {
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.request["output_path"].as_str().unwrap()).unwrap(),
|
||||
"kept"
|
||||
);
|
||||
assert_eq!(fixture.staged_files(), ["snapshot.ndjson"]);
|
||||
} else {
|
||||
assert!(fixture.staged_files().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_queries_share_one_stable_wal_read_snapshot() {
|
||||
let fixture = Fixture::new();
|
||||
let calls = Cell::new(0);
|
||||
execute(&fixture.request, &|| {
|
||||
calls.set(calls.get() + 1);
|
||||
if calls.get() == 3 {
|
||||
fixture
|
||||
.db
|
||||
.execute("INSERT INTO loved_tracks VALUES('new','{}','date')", [])
|
||||
.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fixture
|
||||
.rows()
|
||||
.iter()
|
||||
.filter(|row| row["kind"] == "loved")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.db
|
||||
.query_row("SELECT COUNT(*) FROM loved_tracks", [], |row| row
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'support/library_collections_benchmark.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'paired large collection workers benchmark',
|
||||
() async {
|
||||
final report = {
|
||||
'platform': Platform.operatingSystem,
|
||||
'mode': 'flutter test host',
|
||||
'collection_hydration': await benchmarkCollectionHydration(),
|
||||
'playlist_search': await benchmarkPlaylistSearch(),
|
||||
'playlist_metadata_copy': await benchmarkPlaylistMetadataCopy(),
|
||||
'collection_export': await benchmarkCollectionExport(),
|
||||
'collection_json_export': await benchmarkCollectionJsonExport(),
|
||||
};
|
||||
const output = String.fromEnvironment('COLLECTIONS_BENCHMARK_OUTPUT');
|
||||
if (output.isNotEmpty) {
|
||||
final file = File(output);
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsString(
|
||||
const JsonEncoder.withIndent(' ').convert(report),
|
||||
);
|
||||
}
|
||||
expect(report['collection_hydration'], isA<Map<String, dynamic>>());
|
||||
},
|
||||
skip: !const bool.fromEnvironment('RUN_COLLECTIONS_BENCHMARK'),
|
||||
timeout: const Timeout(Duration(minutes: 3)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_hydration.dart';
|
||||
|
||||
import 'support/sqlite_process_database.dart';
|
||||
|
||||
// Host parity executes the production keyset SQL against real SQLite. Device
|
||||
// integration separately covers the sqflite channel and Rust read route.
|
||||
class _ReadDatabase implements Database, Transaction {
|
||||
_ReadDatabase(this.database);
|
||||
@override
|
||||
final SqliteProcessDatabase database;
|
||||
@override
|
||||
String get path => database.path;
|
||||
final pageSizes = <int>[];
|
||||
bool failProjection = false;
|
||||
|
||||
@override
|
||||
Future<T> transaction<T>(
|
||||
Future<T> Function(Transaction txn) action, {
|
||||
bool? exclusive,
|
||||
}) => database.transaction((_) => action(this), exclusive: exclusive);
|
||||
|
||||
@override
|
||||
Future<void> execute(String sql, [List<Object?>? arguments]) {
|
||||
if (failProjection && sql.startsWith('CREATE TABLE collection_read_')) {
|
||||
throw StateError('projection failed');
|
||||
}
|
||||
return database.execute(sql, arguments);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, Object?>>> rawQuery(
|
||||
String sql, [
|
||||
List<Object?>? arguments,
|
||||
]) async {
|
||||
final rows = await database.rawQuery(sql, arguments);
|
||||
pageSizes.add(rows.length);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => database.close();
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
Future<_ReadDatabase> _fixture(String path, int count) async {
|
||||
final result = await Process.run('python3', [
|
||||
'-c',
|
||||
r"""
|
||||
import json,sqlite3,sys
|
||||
db=sqlite3.connect(sys.argv[1]); count=int(sys.argv[2])
|
||||
db.executescript('''PRAGMA user_version=2;
|
||||
CREATE TABLE wishlist_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL);
|
||||
CREATE TABLE loved_tracks(track_key TEXT PRIMARY KEY,track_json TEXT NOT NULL,added_at TEXT NOT NULL);
|
||||
CREATE TABLE favorite_artists(artist_key TEXT PRIMARY KEY,artist_json TEXT NOT NULL,added_at TEXT NOT NULL);
|
||||
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);
|
||||
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));''')
|
||||
for p in range(4):
|
||||
db.execute('INSERT INTO playlists VALUES(?,?,?,?,?)',(str(p),'音楽 '+str(p),'/custom.jpg' if p==1 else None,'2026-10-01','2026-10-01'))
|
||||
for i in range(count):
|
||||
track=json.dumps(dict(id=str(i),name='Track '+str(i),artistName='Artist',albumName='Album',duration=180,coverUrl='cover-'+str(i)),ensure_ascii=False)
|
||||
date='2026-10-0'+str(i%3+1)
|
||||
db.execute('INSERT INTO wishlist_tracks VALUES(?,?,?)',('example:'+str(i),track,date))
|
||||
db.execute('INSERT INTO playlist_tracks VALUES(?,?,?,?)',(str(i%4),'example:'+str(i),track,date))
|
||||
db.execute('INSERT INTO loved_tracks VALUES(?,?,?)',('corrupt','{','2026-10-01'))
|
||||
db.execute('INSERT INTO favorite_artists VALUES(?,?,?)',('example:artist',json.dumps(dict(artistId='artist',providerId='example',name='İstanbul')),'2026-10-01'))
|
||||
db.commit()
|
||||
""",
|
||||
path,
|
||||
'$count',
|
||||
]);
|
||||
if (result.exitCode != 0) throw StateError(result.stderr.toString());
|
||||
final db = _ReadDatabase(await SqliteProcessDatabase.open(path: path));
|
||||
addTearDown(db.close);
|
||||
return db;
|
||||
}
|
||||
|
||||
Future<LibraryCollectionsSnapshot> _legacy(
|
||||
_ReadDatabase db,
|
||||
) async => LibraryCollectionsSnapshot(
|
||||
wishlistRows: await db.rawQuery(
|
||||
'SELECT * FROM wishlist_tracks ORDER BY added_at DESC,rowid DESC',
|
||||
),
|
||||
lovedRows: await db.rawQuery(
|
||||
'SELECT * FROM loved_tracks ORDER BY added_at DESC,rowid DESC',
|
||||
),
|
||||
favoriteArtistRows: await db.rawQuery(
|
||||
'SELECT * FROM favorite_artists ORDER BY added_at DESC,rowid DESC',
|
||||
),
|
||||
playlistRows: await db.rawQuery(
|
||||
"""SELECT p.*,CASE WHEN p.cover_image_path IS NULL OR p.cover_image_path='' THEN
|
||||
(SELECT pt.track_json FROM playlist_tracks pt WHERE pt.playlist_id=p.id ORDER BY pt.added_at ASC,pt.rowid ASC LIMIT 1)
|
||||
END AS preview_track_json FROM playlists p ORDER BY p.created_at DESC,p.rowid DESC""",
|
||||
),
|
||||
playlistTrackRows: await db.rawQuery(
|
||||
'SELECT playlist_id,track_key FROM playlist_tracks ORDER BY playlist_id ASC,rowid ASC',
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('collection-db-read-test-');
|
||||
});
|
||||
tearDown(() => root.delete(recursive: true));
|
||||
|
||||
test(
|
||||
'keyset pages preserve all original query rows and tied-date ordering',
|
||||
() async {
|
||||
final db = await _fixture('${root.path}/library_collections.db', 1200);
|
||||
final actual = await LibraryCollectionsDatabase.readDatabaseSnapshot(
|
||||
db,
|
||||
forceLegacy: true,
|
||||
);
|
||||
expect(db.pageSizes.every((size) => size <= 256), isTrue);
|
||||
final expected = await _legacy(db);
|
||||
expect(actual.wishlistRows, expected.wishlistRows);
|
||||
expect(actual.lovedRows, expected.lovedRows);
|
||||
expect(actual.favoriteArtistRows, expected.favoriteArtistRows);
|
||||
expect(actual.playlistRows, expected.playlistRows);
|
||||
expect(actual.playlistTrackRows, expected.playlistTrackRows);
|
||||
db.pageSizes.clear();
|
||||
final tracks =
|
||||
await LibraryCollectionsDatabase.readDatabasePlaylistTracks(
|
||||
db,
|
||||
'1',
|
||||
forceLegacy: true,
|
||||
);
|
||||
expect(db.pageSizes.every((size) => size <= 256), isTrue);
|
||||
expect(
|
||||
tracks.rows,
|
||||
await db.rawQuery(
|
||||
'SELECT * FROM playlist_tracks WHERE playlist_id=? ORDER BY added_at ASC,rowid ASC',
|
||||
['1'],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'small collection stays on bounded SQLite reads without native job',
|
||||
() async {
|
||||
final db = await _fixture('${root.path}/library_collections.db', 3);
|
||||
final snapshot = await LibraryCollectionsDatabase.readDatabaseSnapshot(
|
||||
db,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: (_) async => throw StateError('must not run'),
|
||||
);
|
||||
expect(snapshot.nativeRowsPath, isNull);
|
||||
expect(snapshot.wishlistRows.length, 3);
|
||||
expect(db.pageSizes.every((size) => size <= 64), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'large read routes only probe and staged path, hydrates and cleans',
|
||||
() async {
|
||||
final db = await _fixture('${root.path}/library_collections.db', 600);
|
||||
final expected = await _legacy(db);
|
||||
db.pageSizes.clear();
|
||||
var calls = 0;
|
||||
final snapshot = await LibraryCollectionsDatabase.readDatabaseSnapshot(
|
||||
db,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: (request) async {
|
||||
calls++;
|
||||
expect(request['operation'], 'collections_snapshot');
|
||||
final projectedPath = request['collections_path'] as String;
|
||||
expect(projectedPath, isNot(db.path));
|
||||
expect(
|
||||
File(projectedPath).parent.path,
|
||||
File(request['output_path'] as String).parent.path,
|
||||
);
|
||||
final attached = await db.database.rawQuery('PRAGMA database_list');
|
||||
expect(attached.map((row) => row['name']), ['main']);
|
||||
final projectedDb = _ReadDatabase(
|
||||
await SqliteProcessDatabase.open(path: projectedPath),
|
||||
);
|
||||
late LibraryCollectionsSnapshot projected;
|
||||
try {
|
||||
projected = await _legacy(projectedDb);
|
||||
expect(projected.wishlistRows, expected.wishlistRows);
|
||||
expect(projected.lovedRows, expected.lovedRows);
|
||||
expect(projected.playlistRows, expected.playlistRows);
|
||||
expect(projected.playlistTrackRows, expected.playlistTrackRows);
|
||||
expect(projected.favoriteArtistRows, expected.favoriteArtistRows);
|
||||
} finally {
|
||||
await projectedDb.close();
|
||||
}
|
||||
// Changing the live rows after DETACH cannot change this read's
|
||||
// already captured projection or its hydrated membership snapshot.
|
||||
await db.execute("DELETE FROM playlist_tracks WHERE playlist_id='0'");
|
||||
final rows = [
|
||||
for (final row in projected.wishlistRows)
|
||||
{'kind': 'wishlist', 'row': row},
|
||||
for (final row in projected.lovedRows)
|
||||
{'kind': 'loved', 'row': row},
|
||||
for (final row in projected.playlistRows)
|
||||
{'kind': 'playlist', 'row': row},
|
||||
for (final row in projected.playlistTrackRows)
|
||||
{'kind': 'membership', 'row': row},
|
||||
for (final row in projected.favoriteArtistRows)
|
||||
{'kind': 'artist', 'row': row},
|
||||
];
|
||||
await File(
|
||||
request['output_path'] as String,
|
||||
).writeAsString(rows.map(jsonEncode).join('\n'));
|
||||
return {
|
||||
'published': true,
|
||||
'rows_path': request['output_path'],
|
||||
'counts': {'rows': rows.length},
|
||||
};
|
||||
},
|
||||
);
|
||||
expect(calls, 1);
|
||||
expect(db.pageSizes, [64]);
|
||||
expect(snapshot.wishlistRows, isEmpty);
|
||||
final stagedDirectory = snapshot.nativeRowsDirectory!;
|
||||
final actual = await hydrateLibraryCollectionsSnapshot(snapshot);
|
||||
expect(
|
||||
jsonEncode(actual.toJson()),
|
||||
jsonEncode(decodeLibraryCollectionsSnapshot(expected).toJson()),
|
||||
);
|
||||
for (final playlist in actual.playlists) {
|
||||
expect(playlist.trackCount, 150);
|
||||
}
|
||||
expect(await Directory(stagedDirectory).exists(), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'lazy read projects only the selected playlist and preserves ties',
|
||||
() async {
|
||||
final db = await _fixture('${root.path}/library_collections.db', 800);
|
||||
final expected = await db.rawQuery(
|
||||
'SELECT * FROM playlist_tracks WHERE playlist_id=? ORDER BY added_at ASC,rowid ASC',
|
||||
['1'],
|
||||
);
|
||||
final snapshot =
|
||||
await LibraryCollectionsDatabase.readDatabasePlaylistTracks(
|
||||
db,
|
||||
'1',
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: (request) async {
|
||||
expect(request['playlist_id'], '1');
|
||||
final projectedPath = request['collections_path'] as String;
|
||||
expect(projectedPath, isNot(db.path));
|
||||
final projected = await SqliteProcessDatabase.open(
|
||||
path: projectedPath,
|
||||
);
|
||||
try {
|
||||
for (final table in [
|
||||
'wishlist_tracks',
|
||||
'loved_tracks',
|
||||
'favorite_artists',
|
||||
]) {
|
||||
expect(
|
||||
await projected.rawQuery('SELECT * FROM $table'),
|
||||
isEmpty,
|
||||
);
|
||||
}
|
||||
expect(await projected.rawQuery('SELECT id FROM playlists'), [
|
||||
{'id': '1'},
|
||||
]);
|
||||
expect(await projected.rawQuery('PRAGMA user_version'), [
|
||||
{'user_version': 2},
|
||||
]);
|
||||
final rows = await projected.rawQuery(
|
||||
'SELECT * FROM playlist_tracks ORDER BY added_at ASC,rowid ASC',
|
||||
);
|
||||
expect(rows, expected);
|
||||
await File(request['output_path'] as String).writeAsString(
|
||||
rows
|
||||
.map(
|
||||
(row) =>
|
||||
jsonEncode({'kind': 'playlist_track', 'row': row}),
|
||||
)
|
||||
.join('\n'),
|
||||
);
|
||||
return {
|
||||
'published': true,
|
||||
'rows_path': request['output_path'],
|
||||
'counts': {'playlist_track': rows.length},
|
||||
};
|
||||
} finally {
|
||||
await projected.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
final staged = snapshot.nativeRowsDirectory!;
|
||||
final tracks = await hydratePlaylistTracksSnapshot(
|
||||
snapshot,
|
||||
knownKeys: expected.map((row) => row['track_key'] as String).toSet(),
|
||||
);
|
||||
expect(tracks.membershipUnchanged, true);
|
||||
expect(tracks.tracks, hasLength(200));
|
||||
expect(await Directory(staged).exists(), false);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'failed projection detaches private database and removes staging',
|
||||
() async {
|
||||
final db = await _fixture('${root.path}/library_collections.db', 80);
|
||||
db.failProjection = true;
|
||||
await expectLater(
|
||||
LibraryCollectionsDatabase.readDatabaseSnapshot(
|
||||
db,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: (_) async => throw StateError('must not run'),
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
final databases = await db.database.rawQuery('PRAGMA database_list');
|
||||
expect(databases.map((row) => row['name']), ['main']);
|
||||
expect(
|
||||
await File(databases.single['file'] as String).resolveSymbolicLinks(),
|
||||
await File(db.path).resolveSymbolicLinks(),
|
||||
);
|
||||
expect(root.listSync().whereType<Directory>(), isEmpty);
|
||||
expect(await db.database.rawQuery('PRAGMA integrity_check'), [
|
||||
{'integrity_check': 'ok'},
|
||||
]);
|
||||
db.failProjection = false;
|
||||
final rows = await LibraryCollectionsDatabase.readDatabaseSnapshot(
|
||||
db,
|
||||
forceLegacy: true,
|
||||
);
|
||||
expect(rows.wishlistRows, hasLength(80));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'bad native result and native failure remove their staged directories',
|
||||
() async {
|
||||
final db = await _fixture('${root.path}/library_collections.db', 80);
|
||||
for (final fail in [true, false]) {
|
||||
await expectLater(
|
||||
LibraryCollectionsDatabase.readDatabaseSnapshot(
|
||||
db,
|
||||
temporaryDirectory: root,
|
||||
nativeJobRunner: (request) async {
|
||||
await File(
|
||||
request['output_path'] as String,
|
||||
).writeAsString('partial');
|
||||
if (fail) throw StateError('cancelled');
|
||||
return {
|
||||
'published': true,
|
||||
'rows_path': request['output_path'],
|
||||
'counts': {'rows': -1},
|
||||
};
|
||||
},
|
||||
),
|
||||
throwsA(anything),
|
||||
);
|
||||
expect(root.listSync().whereType<Directory>(), isEmpty);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/library_collections.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_hydration.dart';
|
||||
|
||||
Map<String, dynamic> trackRow(int index) => {
|
||||
'track_key': 'example:$index',
|
||||
'track_json': jsonEncode(
|
||||
Track(
|
||||
id: '$index',
|
||||
name: 'Song $index',
|
||||
artistName: 'Artist $index',
|
||||
albumName: 'Album',
|
||||
duration: 180,
|
||||
source: 'example',
|
||||
coverUrl: 'https://example.com/cover-$index.jpg',
|
||||
availability: ServiceAvailability(
|
||||
tidal: true,
|
||||
qobuz: false,
|
||||
amazon: false,
|
||||
deezer: false,
|
||||
),
|
||||
).toJson(),
|
||||
),
|
||||
'added_at': '2026-01-02T03:04:05Z',
|
||||
};
|
||||
|
||||
LibraryCollectionsSnapshot collectionFixture(int count) =>
|
||||
LibraryCollectionsSnapshot(
|
||||
wishlistRows: List.generate(count, trackRow),
|
||||
lovedRows: [
|
||||
trackRow(2),
|
||||
{'track_key': 'bad', 'track_json': '{'},
|
||||
],
|
||||
favoriteArtistRows: [
|
||||
{
|
||||
'artist_key': 'example:artist',
|
||||
'artist_json': jsonEncode({
|
||||
'artistId': 'artist',
|
||||
'providerId': 'example',
|
||||
'name': 'Artist',
|
||||
'imageUrl': 'https://example.com/artist.jpg',
|
||||
'addedAt': '2025-12-01T00:00:00Z',
|
||||
}),
|
||||
'added_at': '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{'artist_key': 'bad', 'artist_json': '[]'},
|
||||
],
|
||||
playlistRows: [
|
||||
{
|
||||
'id': 'playlist',
|
||||
'name': 'Playlist',
|
||||
'created_at': '2026-01-01T00:00:00Z',
|
||||
'updated_at': 'invalid',
|
||||
'preview_track_json': jsonEncode({'coverUrl': 'cover'}),
|
||||
},
|
||||
{'id': ''},
|
||||
{
|
||||
'id': 'other',
|
||||
'name': 'Other',
|
||||
'created_at': '2026-01-01T00:00:00Z',
|
||||
'preview_track_json': '{',
|
||||
},
|
||||
],
|
||||
playlistTrackRows: [
|
||||
{'playlist_id': 'playlist', 'track_key': 'example:2'},
|
||||
{'playlist_id': 'playlist', 'track_key': 'example:2'},
|
||||
{'playlist_id': 'playlist', 'track_key': 'example:3'},
|
||||
{'playlist_id': 'orphan', 'track_key': 'example:orphan'},
|
||||
{'playlist_id': '', 'track_key': 'example:ignored'},
|
||||
{'playlist_id': 'other', 'track_key': ''},
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'native row file preserves snapshot parity and is always cleaned',
|
||||
() async {
|
||||
final snapshot = collectionFixture(80);
|
||||
final temporary = await Directory.systemTemp.createTemp(
|
||||
'collection-read-test-',
|
||||
);
|
||||
final file = File('${temporary.path}/rows.ndjson');
|
||||
final rows = [
|
||||
for (final row in snapshot.wishlistRows)
|
||||
{'kind': 'wishlist', 'row': row},
|
||||
for (final row in snapshot.lovedRows) {'kind': 'loved', 'row': row},
|
||||
for (final row in snapshot.playlistRows)
|
||||
{'kind': 'playlist', 'row': row},
|
||||
for (final row in snapshot.playlistTrackRows)
|
||||
{'kind': 'membership', 'row': row},
|
||||
for (final row in snapshot.favoriteArtistRows)
|
||||
{'kind': 'artist', 'row': row},
|
||||
];
|
||||
await file.writeAsString(rows.map(jsonEncode).join('\n'));
|
||||
final native = LibraryCollectionsSnapshot(
|
||||
wishlistRows: const [],
|
||||
lovedRows: const [],
|
||||
playlistRows: const [],
|
||||
playlistTrackRows: const [],
|
||||
favoriteArtistRows: const [],
|
||||
nativeRowsPath: file.path,
|
||||
nativeRowsDirectory: temporary.path,
|
||||
nativeRowCount: rows.length,
|
||||
);
|
||||
final actual = await hydrateLibraryCollectionsSnapshot(native);
|
||||
expect(
|
||||
jsonEncode(actual.toJson()),
|
||||
jsonEncode(decodeLibraryCollectionsSnapshot(snapshot).toJson()),
|
||||
);
|
||||
expect(actual.playlistById('playlist')!.trackKeys, {
|
||||
'example:2',
|
||||
'example:3',
|
||||
});
|
||||
expect(await temporary.exists(), isFalse);
|
||||
for (final content in [
|
||||
'not json',
|
||||
'${jsonEncode(rows.first)}\n',
|
||||
jsonEncode({'kind': 'unknown', 'row': <String, dynamic>{}}),
|
||||
]) {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'collection-read-test-',
|
||||
);
|
||||
final path = '${directory.path}/rows.ndjson';
|
||||
await File(path).writeAsString(content);
|
||||
await expectLater(
|
||||
hydrateLibraryCollectionsSnapshot(
|
||||
LibraryCollectionsSnapshot(
|
||||
wishlistRows: const [],
|
||||
lovedRows: const [],
|
||||
playlistRows: const [],
|
||||
playlistTrackRows: const [],
|
||||
favoriteArtistRows: const [],
|
||||
nativeRowsPath: path,
|
||||
nativeRowsDirectory: directory.path,
|
||||
nativeRowCount: rows.length,
|
||||
),
|
||||
),
|
||||
throwsA(isA<Error>()),
|
||||
);
|
||||
expect(await directory.exists(), isFalse);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'native playlist rows preserve track index and remove their staging',
|
||||
() async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'collection-read-playlist-',
|
||||
);
|
||||
final path = '${directory.path}/rows.ndjson';
|
||||
await File(path).writeAsString(
|
||||
List.generate(
|
||||
80,
|
||||
(index) =>
|
||||
jsonEncode({'kind': 'playlist_track', 'row': trackRow(index)}),
|
||||
).join('\n'),
|
||||
);
|
||||
final known = List.generate(80, (index) => 'example:$index').toSet();
|
||||
final loaded = await hydratePlaylistTracksSnapshot(
|
||||
LibraryPlaylistTracksSnapshot(
|
||||
nativeRowsPath: path,
|
||||
nativeRowsDirectory: directory.path,
|
||||
nativeRowCount: 80,
|
||||
),
|
||||
knownKeys: known,
|
||||
);
|
||||
expect(loaded.tracks.length, 80);
|
||||
expect(loaded.membershipUnchanged, isTrue);
|
||||
expect(loaded.trackKeys, known);
|
||||
expect(await directory.exists(), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'export worker keeps category/playlist/track order and original snapshot',
|
||||
() async {
|
||||
final base = decodeLibraryCollectionsSnapshot(collectionFixture(20000));
|
||||
final entries = base.wishlist.take(80).toList();
|
||||
final state = base.copyWith(
|
||||
playlists: [
|
||||
base.playlists.first.copyWith(
|
||||
tracks: entries,
|
||||
coverImagePath: () => '/covers/custom.jpg',
|
||||
),
|
||||
base.playlists.last.copyWith(tracks: entries.reversed.toList()),
|
||||
],
|
||||
);
|
||||
final expected = jsonEncode(state.toJson());
|
||||
var ticks = 0;
|
||||
final timer = Timer.periodic(
|
||||
const Duration(milliseconds: 1),
|
||||
(_) => ticks++,
|
||||
);
|
||||
final pending = exportLibraryCollectionsState(state);
|
||||
final newer = state.copyWith(wishlist: const [], playlists: const []);
|
||||
final actual = await pending;
|
||||
timer.cancel();
|
||||
expect(jsonEncode(actual), expected);
|
||||
final json = await exportLibraryCollectionsJson(state);
|
||||
expect(json, expected);
|
||||
expect(jsonDecode(json), jsonDecode(expected));
|
||||
expect(newer.wishlistCount, 0);
|
||||
expect(ticks, greaterThan(1));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'JSON fragment worker preserves escaping and small empty categories',
|
||||
() async {
|
||||
final state = LibraryCollectionsState(
|
||||
wishlist: [
|
||||
CollectionTrackEntry(
|
||||
key: 'example:escaped',
|
||||
addedAt: DateTime.utc(2026),
|
||||
track: const Track(
|
||||
id: 'escaped',
|
||||
name: '"音楽"\n\\line\t',
|
||||
artistName: 'İstanbul 🎵',
|
||||
albumName: '',
|
||||
duration: 180,
|
||||
comment: 'comma , colon : braces {} bracket []',
|
||||
source: 'example',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final json = await exportLibraryCollectionsJson(state);
|
||||
expect(json, jsonEncode(state.toJson()));
|
||||
expect((jsonDecode(json) as Map)['loved'], isEmpty);
|
||||
expect(
|
||||
await exportLibraryCollectionsJson(LibraryCollectionsState()),
|
||||
jsonEncode(LibraryCollectionsState().toJson()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (final count in [3, 80]) {
|
||||
test(
|
||||
'collection hydration preserves row order and lazy indices ($count)',
|
||||
() async {
|
||||
final snapshot = collectionFixture(count);
|
||||
final expected = decodeLibraryCollectionsSnapshot(snapshot);
|
||||
final actual = await hydrateLibraryCollectionsSnapshot(snapshot);
|
||||
expect(jsonEncode(actual.toJson()), jsonEncode(expected.toJson()));
|
||||
expect(
|
||||
actual.wishlist.map((entry) => entry.track.id),
|
||||
List.generate(count, (index) => '$index'),
|
||||
);
|
||||
expect(actual.loved.map((entry) => entry.key), ['example:2']);
|
||||
expect(actual.favoriteArtists.single.addedAt, DateTime.utc(2025, 12));
|
||||
expect(actual.containsFavoriteArtistKey('example:artist'), isTrue);
|
||||
final playlist = actual.playlistById('playlist')!;
|
||||
expect(playlist.trackKeys, {'example:2', 'example:3'});
|
||||
expect(playlist.tracks, isEmpty);
|
||||
expect(playlist.tracksLoaded, isFalse);
|
||||
expect(playlist.previewCover, 'cover');
|
||||
expect(playlist.updatedAt, playlist.createdAt);
|
||||
expect(actual.playlistById('other')!.previewCover, isNull);
|
||||
expect(actual.isTrackInAnyPlaylist('example:2'), isTrue);
|
||||
expect(actual.isTrackInAnyPlaylist('example:orphan'), isFalse);
|
||||
expect(actual.isLoaded, isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'playlist hydration retains duplicates and removes corrupt memberships',
|
||||
() async {
|
||||
final rows = [
|
||||
...List.generate(80, trackRow),
|
||||
trackRow(0),
|
||||
{'track_key': 'corrupt', 'track_json': 'not json'},
|
||||
{'track_key': '', 'track_json': '{}'},
|
||||
{'track_key': 'array', 'track_json': '[]'},
|
||||
{'track_key': 'missing-fields', 'track_json': '{}'},
|
||||
];
|
||||
final validKeys = List.generate(80, (index) => 'example:$index').toSet();
|
||||
final corrupted = await hydratePlaylistTracks(
|
||||
rows,
|
||||
knownKeys: {...validKeys, 'corrupt'},
|
||||
);
|
||||
expect(corrupted.tracks.length, 81);
|
||||
expect(corrupted.tracks.last.key, 'example:0');
|
||||
expect(corrupted.trackKeys, validKeys);
|
||||
expect(corrupted.membershipUnchanged, isFalse);
|
||||
final valid = await hydratePlaylistTracks(rows, knownKeys: validKeys);
|
||||
expect(valid.membershipUnchanged, isTrue);
|
||||
final playlist = UserPlaylistCollection(
|
||||
id: 'playlist',
|
||||
name: 'Playlist',
|
||||
createdAt: DateTime.utc(2026),
|
||||
updatedAt: DateTime.utc(2026),
|
||||
tracks: const [],
|
||||
tracksLoaded: false,
|
||||
trackKeys: validKeys,
|
||||
);
|
||||
final loaded = playlist.withHydratedTracks(
|
||||
tracks: valid.tracks,
|
||||
trackKeys: valid.trackKeys,
|
||||
membershipUnchanged: valid.membershipUnchanged,
|
||||
);
|
||||
expect(loaded.sharesMembershipIndexWith(playlist), isTrue);
|
||||
expect(loaded.tracksLoaded, isTrue);
|
||||
expect(loaded.trackCount, 80);
|
||||
},
|
||||
);
|
||||
|
||||
test('large collection parsing leaves caller timers able to run', () async {
|
||||
final snapshot = collectionFixture(20000);
|
||||
var ticks = 0;
|
||||
final timer = Timer.periodic(
|
||||
const Duration(milliseconds: 1),
|
||||
(_) => ticks++,
|
||||
);
|
||||
final loaded = await hydrateLibraryCollectionsSnapshot(snapshot);
|
||||
timer.cancel();
|
||||
expect(loaded.wishlistCount, 20000);
|
||||
expect(loaded.containsWishlistKey('example:19999'), isTrue);
|
||||
// The old synchronous parsing path cannot service this timer while parsing.
|
||||
expect(ticks, greaterThan(1));
|
||||
});
|
||||
|
||||
test(
|
||||
'worker failure releases a pending batch and allows subsequent loads',
|
||||
() async {
|
||||
final valid = collectionFixture(80);
|
||||
final invalid = LibraryCollectionsSnapshot(
|
||||
wishlistRows: valid.wishlistRows,
|
||||
lovedRows: valid.lovedRows,
|
||||
favoriteArtistRows: valid.favoriteArtistRows,
|
||||
playlistRows: valid.playlistRows,
|
||||
playlistTrackRows: [
|
||||
{'playlist_id': 123, 'track_key': 'invalid-type'},
|
||||
],
|
||||
);
|
||||
await expectLater(
|
||||
hydrateLibraryCollectionsSnapshot(invalid),
|
||||
throwsA(isA<Error>()),
|
||||
);
|
||||
final subsequent = await hydrateLibraryCollectionsSnapshot(valid);
|
||||
expect(subsequent.wishlistCount, 80);
|
||||
expect(subsequent.playlistById('playlist')!.trackCount, 2);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -11,6 +13,13 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
|
||||
final loved = <String, Map<String, dynamic>>{};
|
||||
final artists = <String, Map<String, dynamic>>{};
|
||||
final playlists = <String, Map<String, dynamic>>{};
|
||||
final playlistTracks = <String, List<Map<String, dynamic>>>{};
|
||||
final playlistReadStarted = Completer<void>();
|
||||
final releasePlaylistRead = Completer<void>();
|
||||
int playlistReads = 0;
|
||||
LibraryPlaylistTracksSnapshot? stagedPlaylistRows;
|
||||
Future<LibraryCollectionsSnapshot>? pendingSnapshot;
|
||||
final snapshotReadStarted = Completer<void>();
|
||||
final events = <String>[];
|
||||
final writeStarted = Completer<void>();
|
||||
final releaseWrite = Completer<void>();
|
||||
@@ -33,14 +42,56 @@ class _CollectionsDatabase implements LibraryCollectionsDatabase {
|
||||
Future<bool> migrateFromSharedPreferences() async => false;
|
||||
|
||||
@override
|
||||
Future<LibraryCollectionsSnapshot> loadSnapshot() async =>
|
||||
LibraryCollectionsSnapshot(
|
||||
wishlistRows: wishlist.values.toList(),
|
||||
lovedRows: loved.values.toList(),
|
||||
playlistRows: playlists.values.toList(),
|
||||
playlistTrackRows: const [],
|
||||
favoriteArtistRows: artists.values.toList(),
|
||||
);
|
||||
Future<LibraryCollectionsSnapshot> loadSnapshot() async {
|
||||
if (!snapshotReadStarted.isCompleted) snapshotReadStarted.complete();
|
||||
return pendingSnapshot ??
|
||||
Future.value(
|
||||
LibraryCollectionsSnapshot(
|
||||
wishlistRows: wishlist.values.toList(),
|
||||
lovedRows: loved.values.toList(),
|
||||
playlistRows: playlists.values.toList(),
|
||||
playlistTrackRows: [
|
||||
for (final entry in playlistTracks.entries)
|
||||
for (final row in entry.value)
|
||||
{'playlist_id': entry.key, 'track_key': row['track_key']},
|
||||
],
|
||||
favoriteArtistRows: artists.values.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> loadPlaylistTracks(
|
||||
String playlistId,
|
||||
) async {
|
||||
playlistReads++;
|
||||
if (!playlistReadStarted.isCompleted) {
|
||||
playlistReadStarted.complete();
|
||||
await releasePlaylistRead.future;
|
||||
}
|
||||
return playlistTracks[playlistId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPlaylistTracksSnapshot> loadPlaylistTracksSnapshot(
|
||||
String playlistId,
|
||||
) async {
|
||||
final rows = await loadPlaylistTracks(playlistId);
|
||||
return stagedPlaylistRows ?? LibraryPlaylistTracksSnapshot(rows: rows);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> renamePlaylist({
|
||||
required String playlistId,
|
||||
required String name,
|
||||
required String updatedAt,
|
||||
}) => _write('rename:$playlistId', () {
|
||||
playlists[playlistId] = {
|
||||
...playlists[playlistId]!,
|
||||
'name': name,
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
Future<void> _upsertTrack(
|
||||
Map<String, Map<String, dynamic>> rows,
|
||||
@@ -138,6 +189,108 @@ ProviderContainer _container(_CollectionsDatabase db) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('disposed snapshot read removes native rows before returning', () async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'collection-read-disposed-',
|
||||
);
|
||||
final path = '${directory.path}/rows.ndjson';
|
||||
await File(path).writeAsString('must never be parsed');
|
||||
final reply = Completer<LibraryCollectionsSnapshot>();
|
||||
final db = _CollectionsDatabase()..pendingSnapshot = reply.future;
|
||||
final container = _container(db);
|
||||
final notifier = container.read(libraryCollectionsProvider.notifier);
|
||||
final done = notifier.ensurePlaylistLoaded('missing');
|
||||
await db.snapshotReadStarted.future;
|
||||
container.dispose();
|
||||
reply.complete(
|
||||
LibraryCollectionsSnapshot(
|
||||
wishlistRows: const [],
|
||||
lovedRows: const [],
|
||||
playlistRows: const [],
|
||||
playlistTrackRows: const [],
|
||||
favoriteArtistRows: const [],
|
||||
nativeRowsPath: path,
|
||||
nativeRowsDirectory: directory.path,
|
||||
nativeRowCount: 1,
|
||||
),
|
||||
);
|
||||
await done;
|
||||
expect(await directory.exists(), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'playlist worker load is single-flight and retains intervening rename',
|
||||
() async {
|
||||
final db = _CollectionsDatabase();
|
||||
db.playlists['playlist'] = {
|
||||
'id': 'playlist',
|
||||
'name': 'Original',
|
||||
'created_at': '2026-01-01T00:00:00Z',
|
||||
'updated_at': '2026-01-01T00:00:00Z',
|
||||
};
|
||||
db.playlistTracks['playlist'] = List.generate(
|
||||
100,
|
||||
(index) => {
|
||||
'track_key': 'example:$index',
|
||||
'track_json': jsonEncode(_track('$index')),
|
||||
'added_at': '2026-01-01T00:00:00Z',
|
||||
},
|
||||
);
|
||||
final container = _container(db);
|
||||
final notifier = container.read(libraryCollectionsProvider.notifier);
|
||||
await notifier.ensurePlaylistsLoaded(const []);
|
||||
final first = notifier.ensurePlaylistLoaded('playlist');
|
||||
await db.playlistReadStarted.future;
|
||||
final second = notifier.ensurePlaylistLoaded('playlist');
|
||||
final rename = notifier.renamePlaylist('playlist', 'Renamed');
|
||||
await db.writeStarted.future;
|
||||
db.releaseWrite.complete();
|
||||
await rename;
|
||||
db.releasePlaylistRead.complete();
|
||||
await Future.wait([first, second]);
|
||||
final playlist = container
|
||||
.read(libraryCollectionsProvider)
|
||||
.playlistById('playlist')!;
|
||||
expect(db.playlistReads, 1);
|
||||
expect(playlist.name, 'Renamed');
|
||||
expect(playlist.tracksLoaded, isTrue);
|
||||
expect(playlist.trackCount, 100);
|
||||
expect(
|
||||
playlist.tracks.map((entry) => entry.track.id),
|
||||
List.generate(100, (index) => '$index'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('disposed playlist read cannot publish a worker result', () async {
|
||||
final db = _CollectionsDatabase();
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'collection-read-disposed-',
|
||||
);
|
||||
final path = '${directory.path}/rows.ndjson';
|
||||
await File(path).writeAsString('must never be parsed');
|
||||
db.stagedPlaylistRows = LibraryPlaylistTracksSnapshot(
|
||||
nativeRowsPath: path,
|
||||
nativeRowsDirectory: directory.path,
|
||||
nativeRowCount: 1,
|
||||
);
|
||||
db.playlists['playlist'] = {
|
||||
'id': 'playlist',
|
||||
'name': 'Playlist',
|
||||
'created_at': '2026-01-01T00:00:00Z',
|
||||
};
|
||||
final container = _container(db);
|
||||
final notifier = container.read(libraryCollectionsProvider.notifier);
|
||||
await notifier.ensurePlaylistsLoaded(const []);
|
||||
final loading = notifier.ensurePlaylistLoaded('playlist');
|
||||
await db.playlistReadStarted.future;
|
||||
container.dispose();
|
||||
db.releasePlaylistRead.complete();
|
||||
await loading;
|
||||
expect(db.playlistReads, 1);
|
||||
expect(await directory.exists(), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'late write completion does not publish or start a disposed mutation',
|
||||
() async {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:spotiflac_android/models/library_collections.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_hydration.dart';
|
||||
import 'package:spotiflac_android/services/library_playlist_search.dart';
|
||||
import 'package:spotiflac_android/services/library_search.dart';
|
||||
|
||||
LibraryCollectionsSnapshot _fixture(int count) => LibraryCollectionsSnapshot(
|
||||
wishlistRows: [
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
'track_key': 'example:$index',
|
||||
'track_json': jsonEncode(
|
||||
Track(
|
||||
id: '$index',
|
||||
name: 'Song $index',
|
||||
artistName: 'Artist ${index % 997}',
|
||||
albumName: 'Album ${index ~/ 12}',
|
||||
duration: 180 + index % 240,
|
||||
source: 'example',
|
||||
coverUrl: 'https://example.test/$index.jpg',
|
||||
isrc: 'XX000${index.toString().padLeft(7, '0')}',
|
||||
).toJson(),
|
||||
),
|
||||
'added_at': '2026-10-07T00:00:00Z',
|
||||
},
|
||||
],
|
||||
lovedRows: const [],
|
||||
favoriteArtistRows: const [],
|
||||
playlistRows: [
|
||||
for (var index = 0; index < 4; index++)
|
||||
{
|
||||
'id': 'playlist-$index',
|
||||
'name': 'Playlist $index',
|
||||
'created_at': '2026-10-07T00:00:00Z',
|
||||
'updated_at': '2026-10-07T00:00:00Z',
|
||||
},
|
||||
],
|
||||
playlistTrackRows: [
|
||||
for (var index = 0; index < count * 2; index++)
|
||||
{'playlist_id': 'playlist-${index % 4}', 'track_key': 'example:$index'},
|
||||
],
|
||||
);
|
||||
|
||||
String _stateDigest(LibraryCollectionsState state) => sha256
|
||||
.convert(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'state': state.toJson(),
|
||||
'membership': [
|
||||
for (final playlist in state.playlists)
|
||||
{'id': playlist.id, 'keys': playlist.trackKeys.toList()..sort()},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toString();
|
||||
|
||||
Future<({T value, Map<String, int> timing})> measureCollectionOperation<T>(
|
||||
FutureOr<T> Function() operation,
|
||||
) async {
|
||||
final watch = Stopwatch()..start();
|
||||
var beats = 0;
|
||||
var last = 0;
|
||||
var maximumGap = 0;
|
||||
final timer = Timer.periodic(const Duration(milliseconds: 10), (_) {
|
||||
final now = watch.elapsedMicroseconds;
|
||||
if (now - last > maximumGap) maximumGap = now - last;
|
||||
last = now;
|
||||
beats++;
|
||||
});
|
||||
late T value;
|
||||
try {
|
||||
value = await operation();
|
||||
} finally {
|
||||
watch.stop();
|
||||
timer.cancel();
|
||||
}
|
||||
if (watch.elapsedMicroseconds - last > maximumGap) {
|
||||
maximumGap = watch.elapsedMicroseconds - last;
|
||||
}
|
||||
return (
|
||||
value: value,
|
||||
timing: {
|
||||
'microseconds': watch.elapsedMicroseconds,
|
||||
'heartbeat_count': beats,
|
||||
'maximum_gap_us': maximumGap,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> benchmarkCollectionHydration({
|
||||
int rows = 40000,
|
||||
}) async {
|
||||
final snapshot = await compute(_fixture, rows);
|
||||
final baselineDigest = await compute(
|
||||
_stateDigest,
|
||||
decodeLibraryCollectionsSnapshot(snapshot),
|
||||
);
|
||||
await hydrateLibraryCollectionsSnapshot(snapshot);
|
||||
final baseline = <Map<String, int>>[];
|
||||
final worker = <Map<String, int>>[];
|
||||
for (var sample = 0; sample < 5; sample++) {
|
||||
for (final useWorker in sample.isEven ? [false, true] : [true, false]) {
|
||||
final result = await measureCollectionOperation<LibraryCollectionsState>(
|
||||
() => useWorker
|
||||
? hydrateLibraryCollectionsSnapshot(snapshot)
|
||||
: decodeLibraryCollectionsSnapshot(snapshot),
|
||||
);
|
||||
final digest = await compute(_stateDigest, result.value);
|
||||
if (digest != baselineDigest) {
|
||||
throw StateError('Collection parity failed');
|
||||
}
|
||||
(useWorker ? worker : baseline).add(result.timing);
|
||||
}
|
||||
}
|
||||
return {
|
||||
'rows': rows,
|
||||
'membership_rows': rows * 2,
|
||||
'baseline': baseline,
|
||||
'worker': worker,
|
||||
'digest': baselineDigest,
|
||||
'note': 'Paired hydration latency and caller heartbeat, not displayed FPS.',
|
||||
};
|
||||
}
|
||||
|
||||
List<UserPlaylistCollection> _searchFixture(int count) => [
|
||||
for (var index = count - 1; index >= 0; index--)
|
||||
UserPlaylistCollection(
|
||||
id: '$index',
|
||||
name: switch (index % 4) {
|
||||
0 => 'rock',
|
||||
1 => 'rock mix $index',
|
||||
2 => 'my rock $index',
|
||||
_ => 'mix rock $index',
|
||||
},
|
||||
createdAt: DateTime.utc(2026),
|
||||
updatedAt: DateTime.utc(2026),
|
||||
tracks: const [],
|
||||
tracksLoaded: false,
|
||||
trackKeys: {'example:$index'},
|
||||
),
|
||||
];
|
||||
|
||||
List<LibrarySearchHit> _baselineSearch(List<UserPlaylistCollection> playlists) {
|
||||
final query = LibrarySearchQuery('rock');
|
||||
final matches = playlists.where((p) => query.matches(p.name)).toList()
|
||||
..sort((a, b) {
|
||||
final rank = query.rank(a.name).compareTo(query.rank(b.name));
|
||||
if (rank != 0) return rank;
|
||||
final title = a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
return title != 0 ? title : a.id.compareTo(b.id);
|
||||
});
|
||||
return matches
|
||||
.skip(40)
|
||||
.take(40)
|
||||
.map(
|
||||
(p) => LibrarySearchHit(
|
||||
kind: LibrarySearchKind.playlists,
|
||||
id: p.id,
|
||||
title: p.name,
|
||||
cover: p.coverImagePath ?? p.previewCover,
|
||||
trackCount: p.trackCount,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> benchmarkPlaylistSearch({int rows = 25000}) async {
|
||||
final playlists = await compute(_searchFixture, rows);
|
||||
final expected = _baselineSearch(playlists).map((hit) => hit.id).join(',');
|
||||
await searchLibraryPlaylists(
|
||||
playlists: playlists,
|
||||
query: 'rock',
|
||||
limit: 40,
|
||||
offset: 40,
|
||||
);
|
||||
final baseline = <Map<String, int>>[];
|
||||
final worker = <Map<String, int>>[];
|
||||
for (var sample = 0; sample < 5; sample++) {
|
||||
for (final useWorker in sample.isEven ? [false, true] : [true, false]) {
|
||||
final result = await measureCollectionOperation<List<LibrarySearchHit>>(
|
||||
() => useWorker
|
||||
? searchLibraryPlaylists(
|
||||
playlists: playlists,
|
||||
query: 'rock',
|
||||
limit: 40,
|
||||
offset: 40,
|
||||
)
|
||||
: _baselineSearch(playlists),
|
||||
);
|
||||
if (result.value.map((hit) => hit.id).join(',') != expected) {
|
||||
throw StateError('Playlist search parity failed');
|
||||
}
|
||||
(useWorker ? worker : baseline).add(result.timing);
|
||||
}
|
||||
}
|
||||
return {
|
||||
'rows': rows,
|
||||
'offset': 40,
|
||||
'limit': 40,
|
||||
'baseline': baseline,
|
||||
'worker': worker,
|
||||
'note': 'Original full sort versus production bounded search and worker.',
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> benchmarkPlaylistMetadataCopy({
|
||||
int rows = 100000,
|
||||
}) async {
|
||||
final state = await compute(
|
||||
decodeLibraryCollectionsSnapshot,
|
||||
await compute(_fixture, rows),
|
||||
);
|
||||
final playlists = state.playlists;
|
||||
final baseline = <Map<String, int>>[];
|
||||
final optimized = <Map<String, int>>[];
|
||||
for (var sample = 0; sample < 5; sample++) {
|
||||
for (final reuse in sample.isEven ? [false, true] : [true, false]) {
|
||||
final result = await measureCollectionOperation(() {
|
||||
late LibraryCollectionsState updated;
|
||||
for (var index = 0; index < 10; index++) {
|
||||
final renamed = [
|
||||
playlists.first.copyWith(name: 'Renamed $index'),
|
||||
...playlists.skip(1),
|
||||
];
|
||||
updated = reuse
|
||||
? state.copyWith(playlists: renamed)
|
||||
: LibraryCollectionsState(
|
||||
wishlist: state.wishlist,
|
||||
loved: state.loved,
|
||||
favoriteArtists: state.favoriteArtists,
|
||||
playlists: renamed,
|
||||
isLoaded: state.isLoaded,
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
if (!result.value.isTrackInAnyPlaylist('example:${rows * 2 - 1}') ||
|
||||
result.value.playlists.first.name != 'Renamed 9') {
|
||||
throw StateError('Metadata membership parity failed');
|
||||
}
|
||||
(reuse ? optimized : baseline).add(result.timing);
|
||||
}
|
||||
}
|
||||
return {
|
||||
'membership_rows': rows * 2,
|
||||
'edits_per_sample': 10,
|
||||
'baseline': baseline,
|
||||
'optimized': optimized,
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> benchmarkCollectionExport({
|
||||
int rows = 40000,
|
||||
}) async {
|
||||
final state = await compute(
|
||||
decodeLibraryCollectionsSnapshot,
|
||||
await compute(_fixture, rows),
|
||||
);
|
||||
final expected = await compute(_encodedState, state);
|
||||
await exportLibraryCollectionsState(state);
|
||||
final baseline = <Map<String, int>>[];
|
||||
final worker = <Map<String, int>>[];
|
||||
for (var sample = 0; sample < 5; sample++) {
|
||||
for (final useWorker in sample.isEven ? [false, true] : [true, false]) {
|
||||
final result = await measureCollectionOperation<Map<String, dynamic>>(
|
||||
() => useWorker ? exportLibraryCollectionsState(state) : state.toJson(),
|
||||
);
|
||||
final digest = await compute(_jsonDigest, result.value);
|
||||
if (digest != expected) {
|
||||
throw StateError('Collection export parity failed');
|
||||
}
|
||||
(useWorker ? worker : baseline).add(result.timing);
|
||||
}
|
||||
}
|
||||
return {
|
||||
'rows': rows,
|
||||
'baseline': baseline,
|
||||
'worker': worker,
|
||||
'note':
|
||||
'Model-to-map conversion; worker prioritizes caller responsiveness.',
|
||||
};
|
||||
}
|
||||
|
||||
String _encodedState(LibraryCollectionsState state) =>
|
||||
_jsonDigest(state.toJson());
|
||||
String _jsonDigest(Map<String, dynamic> value) =>
|
||||
sha256.convert(utf8.encode(jsonEncode(value))).toString();
|
||||
|
||||
Future<String> _encodeExportMap(Map<String, dynamic> value) =>
|
||||
Isolate.run(() => jsonEncode(value));
|
||||
Future<String> _legacyJsonExport(LibraryCollectionsState state) =>
|
||||
_encodeExportMap(state.toJson());
|
||||
|
||||
Future<Map<String, dynamic>> benchmarkCollectionJsonExport({
|
||||
int rows = 40000,
|
||||
}) async {
|
||||
final state = await compute(
|
||||
decodeLibraryCollectionsSnapshot,
|
||||
await compute(_fixture, rows),
|
||||
);
|
||||
final expected = await _legacyJsonExport(state);
|
||||
await exportLibraryCollectionsJson(state);
|
||||
final baseline = <Map<String, int>>[];
|
||||
final worker = <Map<String, int>>[];
|
||||
for (var sample = 0; sample < 5; sample++) {
|
||||
for (final useWorker in sample.isEven ? [false, true] : [true, false]) {
|
||||
final result = await measureCollectionOperation<String>(
|
||||
() => useWorker
|
||||
? exportLibraryCollectionsJson(state)
|
||||
: _legacyJsonExport(state),
|
||||
);
|
||||
if (result.value != expected) {
|
||||
throw StateError('Collection JSON export parity failed');
|
||||
}
|
||||
(useWorker ? worker : baseline).add(result.timing);
|
||||
}
|
||||
}
|
||||
return {
|
||||
'rows': rows,
|
||||
'json_characters': expected.length,
|
||||
'baseline': baseline,
|
||||
'worker': worker,
|
||||
'note':
|
||||
'Full model-to-encoded-JSON: original UI map+encoding isolate versus bounded single worker JSON.',
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user