Files
SpotiFLAC-Mobile/lib/services/library_collections_database.dart
T

1205 lines
38 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/models/library_collections.dart';
import 'package:spotiflac_android/services/collection_restore_codec.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/collection_track_batch.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
import 'package:spotiflac_android/services/sqlite_native_snapshot.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LibraryCollectionsDb');
const _dbFileName = 'library_collections.db';
const _dbVersion = 2;
const _tableWishlist = 'wishlist_tracks';
const _tableLoved = 'loved_tracks';
const _tablePlaylists = 'playlists';
const _tablePlaylistTracks = 'playlist_tracks';
const _tableFavoriteArtists = 'favorite_artists';
const _legacyCollectionsStorageKey = 'library_collections_v1';
const _migrationDoneKey = 'library_collections_migrated_to_sqlite_v1';
class LibraryCollectionsSnapshot {
final List<Map<String, dynamic>> wishlistRows;
final List<Map<String, dynamic>> lovedRows;
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,
required this.lovedRows,
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 =
LibraryCollectionsDatabase._init();
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
LibraryCollectionsDatabase._init();
Future<Database> get database {
return _database.getOrCreate(
() => sqlite.openAppDatabase(
_dbFileName,
version: _dbVersion,
foreignKeys: true,
onCreate: _createDb,
onUpgrade: _upgradeDb,
),
);
}
Future<void> _createDb(Database db, int version) async {
_log.i('Creating collections database schema v$version');
await db.execute('''
CREATE TABLE $_tableWishlist (
track_key TEXT PRIMARY KEY,
track_json TEXT NOT NULL,
added_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE $_tableLoved (
track_key TEXT PRIMARY KEY,
track_json TEXT NOT NULL,
added_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE $_tablePlaylists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
cover_image_path TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE $_tablePlaylistTracks (
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 $_tablePlaylists(id) ON DELETE CASCADE
)
''');
await _createFavoriteArtistsTable(db);
await db.execute(
'CREATE INDEX idx_${_tableWishlist}_added_at ON $_tableWishlist(added_at DESC)',
);
await db.execute(
'CREATE INDEX idx_${_tableLoved}_added_at ON $_tableLoved(added_at DESC)',
);
await db.execute(
'CREATE INDEX idx_${_tablePlaylists}_created_at ON $_tablePlaylists(created_at DESC)',
);
await db.execute(
'CREATE INDEX idx_${_tablePlaylistTracks}_playlist_id ON $_tablePlaylistTracks(playlist_id)',
);
await db.execute(
'CREATE INDEX idx_${_tablePlaylistTracks}_added_at ON $_tablePlaylistTracks(added_at DESC)',
);
}
Future<void> _upgradeDb(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading collections database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
await _createFavoriteArtistsTable(db);
}
}
Future<void> _createFavoriteArtistsTable(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $_tableFavoriteArtists (
artist_key TEXT PRIMARY KEY,
artist_json TEXT NOT NULL,
added_at TEXT NOT NULL
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_${_tableFavoriteArtists}_added_at ON $_tableFavoriteArtists(added_at DESC)',
);
}
Future<bool> migrateFromSharedPreferences() async {
final prefs = await _prefs;
if (prefs.getBool(_migrationDoneKey) == true) {
return false;
}
final raw = prefs.getString(_legacyCollectionsStorageKey);
if (raw == null || raw.isEmpty) {
await prefs.setBool(_migrationDoneKey, true);
return false;
}
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
await prefs.setBool(_migrationDoneKey, true);
return false;
}
final root = Map<String, dynamic>.from(decoded);
final wishlistRaw = (root['wishlist'] as List?) ?? const [];
final lovedRaw = (root['loved'] as List?) ?? const [];
final playlistsRaw = (root['playlists'] as List?) ?? const [];
final nowIso = DateTime.now().toIso8601String();
final db = await database;
await db.transaction((txn) async {
for (final entry in wishlistRaw.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry);
final trackKey = map['key'] as String?;
final track = map['track'];
if (trackKey == null || track is! Map<Object?, Object?>) continue;
final addedAt = (map['addedAt'] as String?) ?? nowIso;
await txn.insert(_tableWishlist, {
'track_key': trackKey,
'track_json': jsonEncode(track),
'added_at': addedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
for (final entry in lovedRaw.whereType<Map<Object?, Object?>>()) {
final map = Map<String, dynamic>.from(entry);
final trackKey = map['key'] as String?;
final track = map['track'];
if (trackKey == null || track is! Map<Object?, Object?>) continue;
final addedAt = (map['addedAt'] as String?) ?? nowIso;
await txn.insert(_tableLoved, {
'track_key': trackKey,
'track_json': jsonEncode(track),
'added_at': addedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
for (final playlistEntry
in playlistsRaw.whereType<Map<Object?, Object?>>()) {
final playlist = Map<String, dynamic>.from(playlistEntry);
final playlistId = playlist['id'] as String?;
if (playlistId == null || playlistId.isEmpty) continue;
final createdAt = (playlist['createdAt'] as String?) ?? nowIso;
final updatedAt = (playlist['updatedAt'] as String?) ?? createdAt;
await txn.insert(_tablePlaylists, {
'id': playlistId,
'name': (playlist['name'] as String?) ?? '',
'cover_image_path': playlist['coverImagePath'] as String?,
'created_at': createdAt,
'updated_at': updatedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
final tracksRaw = (playlist['tracks'] as List?) ?? const [];
for (final trackEntry
in tracksRaw.whereType<Map<Object?, Object?>>()) {
final trackMap = Map<String, dynamic>.from(trackEntry);
final trackKey = trackMap['key'] as String?;
final track = trackMap['track'];
if (trackKey == null || track is! Map<Object?, Object?>) continue;
final addedAt = (trackMap['addedAt'] as String?) ?? nowIso;
await txn.insert(_tablePlaylistTracks, {
'playlist_id': playlistId,
'track_key': trackKey,
'track_json': jsonEncode(track),
'added_at': addedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}
});
await prefs.setBool(_migrationDoneKey, true);
_log.i('Migrated legacy collections data to SQLite');
return true;
} catch (e, stack) {
_log.e('Failed migrating collections to SQLite: $e', e, stack);
return false;
}
}
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''',
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.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);
Future<void> _upsertEntry(
String table,
String prefix, {
required String key,
required String json,
required String addedAt,
}) async {
final db = await database;
await db.insert(table, {
'${prefix}_key': key,
'${prefix}_json': json,
'added_at': addedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> _deleteEntry(String table, String prefix, String key) async {
final db = await database;
await db.delete(table, where: '${prefix}_key = ?', whereArgs: [key]);
}
Future<void> upsertWishlistEntry({
required String trackKey,
required String trackJson,
required String addedAt,
}) => _upsertEntry(
_tableWishlist,
'track',
key: trackKey,
json: trackJson,
addedAt: addedAt,
);
Future<void> deleteWishlistEntry(String trackKey) =>
_deleteEntry(_tableWishlist, 'track', trackKey);
Future<void> deleteWishlistTracks(List<String> keys) async {
if (keys.isEmpty) return;
await deleteDatabaseWishlistTracks(await database, keys);
}
static Future<void> deleteDatabaseWishlistTracks(
Database db,
List<String> keys,
) async {
if (keys.isEmpty) return;
await sqlite.transactionWithBusyRetry(
db,
(txn) => _deleteTrackKeys(txn, _tableWishlist, keys),
);
}
Future<void> upsertLovedEntry({
required String trackKey,
required String trackJson,
required String addedAt,
}) => _upsertEntry(
_tableLoved,
'track',
key: trackKey,
json: trackJson,
addedAt: addedAt,
);
Future<void> deleteLovedEntry(String trackKey) =>
_deleteEntry(_tableLoved, 'track', trackKey);
Future<void> upsertLovedTracksBatch(List<Map<String, String>> tracks) async {
if (tracks.isEmpty) return;
await writeDatabaseLovedTracks(await database, tracks);
}
static Future<void> writeDatabaseLovedTracks(
Database db,
List<Map<String, String>> tracks,
) async {
if (tracks.isEmpty) return;
await sqlite.transactionWithBusyRetry(db, (txn) async {
for (var start = 0; start < tracks.length; start += 128) {
final batch = txn.batch();
for (final row in tracks.skip(start).take(128)) {
batch.insert(
_tableLoved,
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
}
});
}
Future<void> upsertLovedTracksFile({
required String addedAt,
required String rowsPath,
required int expectedCount,
}) async => publishLovedBatch(
database: await database,
addedAt: addedAt,
rowsPath: rowsPath,
expectedCount: expectedCount,
);
Future<void> deleteLovedTracks(List<String> keys) async {
if (keys.isEmpty) return;
await deleteDatabaseLovedTracks(await database, keys);
}
static Future<void> deleteDatabaseLovedTracks(
Database db,
List<String> keys,
) async {
if (keys.isEmpty) return;
await sqlite.transactionWithBusyRetry(
db,
(txn) => _deleteTrackKeys(txn, _tableLoved, keys),
);
}
static Future<void> _deleteTrackKeys(
DatabaseExecutor db,
String table,
List<String> keys, {
String? playlistId,
}) async {
for (var start = 0; start < keys.length; start += 128) {
final end = start + 128 < keys.length ? start + 128 : keys.length;
final chunk = keys.sublist(start, end);
await db.delete(
table,
where:
'${playlistId == null ? '' : 'playlist_id = ? AND '}track_key IN (${List.filled(chunk.length, '?').join(',')})',
whereArgs: [?playlistId, ...chunk],
);
}
}
Future<void> upsertFavoriteArtistEntry({
required String artistKey,
required String artistJson,
required String addedAt,
}) => _upsertEntry(
_tableFavoriteArtists,
'artist',
key: artistKey,
json: artistJson,
addedAt: addedAt,
);
Future<void> deleteFavoriteArtistEntry(String artistKey) =>
_deleteEntry(_tableFavoriteArtists, 'artist', artistKey);
Future<void> upsertPlaylist({
required String id,
required String name,
required String createdAt,
required String updatedAt,
String? coverImagePath,
}) async {
final db = await database;
await db.insert(_tablePlaylists, {
'id': id,
'name': name,
'cover_image_path': coverImagePath,
'created_at': createdAt,
'updated_at': updatedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> renamePlaylist({
required String playlistId,
required String name,
required String updatedAt,
}) async {
final db = await database;
await db.update(
_tablePlaylists,
{'name': name, 'updated_at': updatedAt},
where: 'id = ?',
whereArgs: [playlistId],
);
}
Future<void> updatePlaylistCover({
required String playlistId,
required String updatedAt,
String? coverImagePath,
}) async {
final db = await database;
await db.update(
_tablePlaylists,
{'cover_image_path': coverImagePath, 'updated_at': updatedAt},
where: 'id = ?',
whereArgs: [playlistId],
);
}
Future<void> deletePlaylist(String playlistId) async {
final db = await database;
await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]);
}
Future<void> deletePlaylists(List<String> playlistIds) async {
if (playlistIds.isEmpty) return;
await deleteDatabasePlaylists(await database, playlistIds);
}
/// Delete selected parents together; the owner's foreign-key cascade removes
/// their track memberships without loading track metadata into Dart.
static Future<void> deleteDatabasePlaylists(
Database db,
List<String> playlistIds,
) async {
if (playlistIds.isEmpty) return;
await sqlite.transactionWithBusyRetry(db, (txn) async {
for (var start = 0; start < playlistIds.length; start += 128) {
final end = start + 128 < playlistIds.length
? start + 128
: playlistIds.length;
final ids = playlistIds.sublist(start, end);
await txn.delete(
_tablePlaylists,
where: 'id IN (${List.filled(ids.length, '?').join(',')})',
whereArgs: ids,
);
}
});
}
Future<void> upsertPlaylistTrack({
required String playlistId,
required String trackKey,
required String trackJson,
required String addedAt,
required String playlistUpdatedAt,
}) async {
final db = await database;
await db.transaction((txn) async {
await txn.insert(_tablePlaylistTracks, {
'playlist_id': playlistId,
'track_key': trackKey,
'track_json': trackJson,
'added_at': addedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
await txn.update(
_tablePlaylists,
{'updated_at': playlistUpdatedAt},
where: 'id = ?',
whereArgs: [playlistId],
);
});
}
Future<void> upsertPlaylistTracksBatch({
required String playlistId,
required String playlistUpdatedAt,
required List<Map<String, String>> tracks,
}) async {
if (tracks.isEmpty) return;
await writeDatabasePlaylistTracks(
await database,
playlistId: playlistId,
playlistUpdatedAt: playlistUpdatedAt,
tracks: tracks,
);
}
static Future<void> writeDatabasePlaylistTracks(
Database db, {
required String playlistId,
required String playlistUpdatedAt,
required List<Map<String, String>> tracks,
}) async {
if (tracks.isEmpty) return;
await sqlite.transactionWithBusyRetry(db, (txn) async {
var batch = txn.batch();
var pending = 0;
for (final track in tracks) {
batch.insert(_tablePlaylistTracks, {
'playlist_id': playlistId,
'track_key': track['track_key'],
'track_json': track['track_json'],
'added_at': track['added_at'],
}, conflictAlgorithm: ConflictAlgorithm.replace);
if (++pending == 128) {
await batch.commit(noResult: true);
batch = txn.batch();
pending = 0;
}
}
batch.update(
_tablePlaylists,
{'updated_at': playlistUpdatedAt},
where: 'id = ?',
whereArgs: [playlistId],
);
await batch.commit(noResult: true);
});
}
Future<void> upsertPlaylistTracksFile({
required String playlistId,
required String playlistUpdatedAt,
required String rowsPath,
required int expectedCount,
}) async => publishPlaylistBatch(
database: await database,
playlistId: playlistId,
updatedAt: playlistUpdatedAt,
rowsPath: rowsPath,
expectedCount: expectedCount,
);
Future<void> deletePlaylistTrack({
required String playlistId,
required String trackKey,
required String playlistUpdatedAt,
}) async {
final db = await database;
await db.transaction((txn) async {
await txn.delete(
_tablePlaylistTracks,
where: 'playlist_id = ? AND track_key = ?',
whereArgs: [playlistId, trackKey],
);
await txn.update(
_tablePlaylists,
{'updated_at': playlistUpdatedAt},
where: 'id = ?',
whereArgs: [playlistId],
);
});
}
Future<void> deletePlaylistTracks({
required String playlistId,
required List<String> trackKeys,
required String playlistUpdatedAt,
}) async {
if (trackKeys.isEmpty) return;
await deleteDatabasePlaylistTracks(
await database,
playlistId: playlistId,
trackKeys: trackKeys,
playlistUpdatedAt: playlistUpdatedAt,
);
}
static Future<void> deleteDatabasePlaylistTracks(
Database db, {
required String playlistId,
required List<String> trackKeys,
required String playlistUpdatedAt,
}) async {
if (trackKeys.isEmpty) return;
await sqlite.transactionWithBusyRetry(db, (txn) async {
await _deleteTrackKeys(
txn,
_tablePlaylistTracks,
trackKeys,
playlistId: playlistId,
);
await txn.update(
_tablePlaylists,
{'updated_at': playlistUpdatedAt},
where: 'id = ?',
whereArgs: [playlistId],
);
});
}
/// Wipes every collection table and rewrites them from a restored backup.
///
/// [collectionsJson] must use the same shape as
/// `LibraryCollectionsState.toJson()` (wishlist/loved/playlists/favoriteArtists).
/// Track entries carry a nested `track` map (stored as `track_json`); favorite
/// artist entries are stored whole as `artist_json`.
Future<void> replaceAllFromBackup(
Map<String, dynamic> collectionsJson,
) async {
final nowIso = DateTime.now().toIso8601String();
await replaceDatabaseFromBackup(
await database,
collectionsJson,
nowIso: nowIso,
);
}
/// Shares the production restore route with isolated database integration
/// fixtures. The caller initializes the app-owned schema before native IO.
static Future<void> replaceDatabaseFromBackup(
Database db,
Map<String, dynamic> collectionsJson, {
String? nowIso,
}) async {
final restoreTime = nowIso ?? DateTime.now().toIso8601String();
if (PlatformBridge.supportsCoreBackend) {
final temporary = await Directory(
(await getTemporaryDirectory()).path,
).createTemp('collection-restore-');
var detached = true;
try {
final path = '${temporary.path}/commands.ndjson';
final count = await writeCollectionRestoreCommands(
path,
collectionsJson,
restoreTime,
);
// The native backend bundles its own SQLite. Give it a private file
// after sqflite closes that file; only the database owner publishes
// into the live database, preserving its WAL and connection caches.
final stagedPath = '${temporary.path}/$_dbFileName';
final stagedDatabase = await openDatabase(
stagedPath,
version: _dbVersion,
onConfigure: (staged) => staged.execute('PRAGMA foreign_keys=ON'),
onCreate: instance._createDb,
);
detached = false;
await stagedDatabase.close();
detached = true;
final result = await PlatformBridge.runNativeDataJob({
'operation': 'backup_collections_import',
'collections_path': stagedPath,
'ndjson_path': path,
'expected_count': count,
});
if (result['committed'] != true) {
throw const FormatException('Incomplete collection restore');
}
final alias = 'collection_restore_${_restoreAttachmentSequence++}';
await db.execute('ATTACH DATABASE ? AS $alias', [stagedPath]);
var committed = false;
try {
await db.transaction((txn) async {
for (final table in [
_tablePlaylistTracks,
_tablePlaylists,
_tableWishlist,
_tableLoved,
_tableFavoriteArtists,
]) {
await txn.delete(table);
}
for (final (table, columns) in [
(_tableWishlist, 'track_key,track_json,added_at'),
(_tableLoved, 'track_key,track_json,added_at'),
(_tableFavoriteArtists, 'artist_key,artist_json,added_at'),
(
_tablePlaylists,
'id,name,cover_image_path,created_at,updated_at',
),
(
_tablePlaylistTracks,
'playlist_id,track_key,track_json,added_at',
),
]) {
await txn.execute(
'INSERT INTO main.$table($columns) '
'SELECT $columns FROM $alias.$table ORDER BY rowid',
);
}
});
committed = true;
} finally {
try {
await detachNativeSqliteSnapshot(db, alias, stagedPath);
} on NativeSqliteSnapshotDetachException {
detached = false;
if (!committed) rethrow;
_log.w(
'Collections restored; retaining staging after a failed database detach',
);
}
}
} on NativeSqliteSnapshotDetachException {
detached = false;
_log.w(
'Retaining collection restore staging after a failed database detach',
);
rethrow;
} finally {
if (detached) {
try {
await temporary.delete(recursive: true);
} on FileSystemException catch (error) {
_log.w('Could not remove collection restore staging: $error');
}
}
}
_log.i('Restored collections from backup');
return;
}
final commands = await prepareCollectionRestoreCommands(
collectionsJson,
restoreTime,
);
await db.transaction((txn) async {
await txn.delete(_tablePlaylistTracks);
await txn.delete(_tablePlaylists);
await txn.delete(_tableWishlist);
await txn.delete(_tableLoved);
await txn.delete(_tableFavoriteArtists);
var batch = txn.batch();
var pending = 0;
for (final command in commands) {
final table = switch (command['kind']) {
'wishlist' => _tableWishlist,
'loved' => _tableLoved,
'artist' => _tableFavoriteArtists,
'playlist' => _tablePlaylists,
'playlist_track' => _tablePlaylistTracks,
_ => throw const FormatException('Unknown collection command'),
};
batch.insert(
table,
command['values'] as Map<String, dynamic>,
conflictAlgorithm: ConflictAlgorithm.replace,
);
if (++pending == 500) {
await batch.commit(noResult: true);
batch = txn.batch();
pending = 0;
}
}
if (pending > 0) await batch.commit(noResult: true);
});
_log.i('Restored collections from backup');
}
}
/// Reads picker rows with bounded selection queries, preserving playlist order.
Future<List<PlaylistPickerSummary>> readPlaylistPickerSummaries(
DatabaseExecutor db,
List<String> requestedTrackKeys,
) async {
final uniqueTrackKeys = requestedTrackKeys
.where((key) => key.trim().isNotEmpty)
.toSet()
.toList(growable: false);
final playlistRows = await db.rawQuery('''
SELECT
p.id,
p.name,
p.cover_image_path,
p.created_at,
p.updated_at,
COUNT(pt.track_key) AS track_count,
CASE WHEN p.cover_image_path IS NULL OR p.cover_image_path = '' THEN (
SELECT preview.track_json
FROM $_tablePlaylistTracks preview
WHERE preview.playlist_id = p.id
ORDER BY preview.added_at ASC, preview.rowid ASC
LIMIT 1
) END AS preview_track_json
FROM $_tablePlaylists p
LEFT JOIN $_tablePlaylistTracks pt ON pt.playlist_id = p.id
GROUP BY p.id
ORDER BY p.created_at DESC, p.rowid DESC
''');
final matchedCountsByPlaylistId = <String, int>{};
const chunkSize = 500;
for (var offset = 0; offset < uniqueTrackKeys.length; offset += chunkSize) {
final chunk = uniqueTrackKeys.sublist(
offset,
(offset + chunkSize).clamp(0, uniqueTrackKeys.length),
);
final placeholders = List.filled(chunk.length, '?').join(', ');
final matchedRows = await db.rawQuery('''
SELECT playlist_id, COUNT(*) AS matched_count
FROM $_tablePlaylistTracks
WHERE track_key IN ($placeholders)
GROUP BY playlist_id
''', chunk);
for (final row in matchedRows) {
final playlistId = row['playlist_id']?.toString();
if (playlistId == null || playlistId.isEmpty) continue;
matchedCountsByPlaylistId[playlistId] =
(matchedCountsByPlaylistId[playlistId] ?? 0) +
((row['matched_count'] as num?)?.toInt() ?? 0);
}
}
return playlistRows
.map((row) {
final id = row['id']?.toString() ?? '';
final createdAt =
DateTime.tryParse(row['created_at']?.toString() ?? '') ??
DateTime.now();
final updatedAt =
DateTime.tryParse(row['updated_at']?.toString() ?? '') ?? createdAt;
String? previewCover;
final previewJson = row['preview_track_json'] as String?;
if (previewJson != null && previewJson.isNotEmpty) {
try {
final decoded = jsonDecode(previewJson);
if (decoded is Map) {
final cover = decoded['coverUrl']?.toString();
if (cover != null && cover.isNotEmpty) previewCover = cover;
}
} catch (_) {}
}
return PlaylistPickerSummary(
id: id,
name: row['name']?.toString() ?? '',
coverImagePath: row['cover_image_path'] as String?,
previewCover: previewCover,
createdAt: createdAt,
updatedAt: updatedAt,
trackCount: (row['track_count'] as num?)?.toInt() ?? 0,
containsAllRequestedTracks:
uniqueTrackKeys.isNotEmpty &&
matchedCountsByPlaylistId[id] == uniqueTrackKeys.length,
);
})
.toList(growable: false);
}