perf(library): stream scans and optimize queue queries

This commit is contained in:
zarzet
2026-08-18 11:03:05 +07:00
parent 29609b6084
commit 7fc27e8314
28 changed files with 2145 additions and 338 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ class ConversionLibraryService {
final converted = source.toJson()
..['id'] = convertedLibraryItemId(source.id, newFilePath)
..['filePath'] = newFilePath
..['downloadedAt'] = DateTime.now().toIso8601String()
..['downloadedAt'] = DateTime.now().toUtc().toIso8601String()
..['quality'] = newQuality
..['format'] = normalizedConvertedAudioFormat(targetFormat)
..['bitrate'] = convertedBitrate
+5 -5
View File
@@ -31,7 +31,7 @@ class CoverDownloadService {
: safeBaseName.trim();
final tempDir = await Directory.systemTemp.createTemp('save_cover_');
final tempPath = p.join(tempDir.path, 'cover.image');
var iosBookmarkActive = false;
IosSecurityScopedAccess? iosBookmarkAccess;
try {
final download = await PlatformBridge.downloadCoverToFile(
@@ -74,13 +74,13 @@ class CoverDownloadService {
var outputDirectory = settings.downloadDirectory.trim();
if (Platform.isIOS && settings.downloadDirectoryBookmark.isNotEmpty) {
final resolved = await PlatformBridge.startAccessingIosBookmark(
iosBookmarkAccess = await PlatformBridge.startAccessingIosBookmark(
settings.downloadDirectoryBookmark,
);
final resolved = iosBookmarkAccess?.path;
if (resolved == null || resolved.trim().isEmpty) {
throw const FileSystemException('No storage access');
}
iosBookmarkActive = true;
outputDirectory = resolved.trim();
}
if (outputDirectory.isEmpty) {
@@ -100,8 +100,8 @@ class CoverDownloadService {
location: outputPath,
);
} finally {
if (iosBookmarkActive) {
await PlatformBridge.stopAccessingIosBookmark();
if (iosBookmarkAccess != null) {
await PlatformBridge.stopAccessingIosBookmark(iosBookmarkAccess);
}
try {
if (await tempDir.exists()) await tempDir.delete(recursive: true);
+135 -13
View File
@@ -65,7 +65,7 @@ class HistoryBatchLookupRequest {
}
class HistoryDatabase {
static const int schemaVersion = 10;
static const int schemaVersion = 11;
static final HistoryDatabase instance = HistoryDatabase._init();
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
@@ -123,7 +123,14 @@ class HistoryDatabase {
isrc_norm TEXT,
match_key TEXT,
album_key TEXT,
search_text TEXT
search_text TEXT,
sort_track TEXT,
sort_artist TEXT,
sort_album TEXT,
sort_album_artist TEXT,
sort_genre TEXT,
sort_release TEXT,
sort_added INTEGER
)
''');
@@ -139,6 +146,7 @@ class HistoryDatabase {
'CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)',
);
await _createNormalizedIndexes(db);
await _createQueueIndexes(db);
await _createPathKeyTable(db);
_log.i('Database schema created with indexes');
@@ -204,6 +212,23 @@ class HistoryDatabase {
await _backfillNormalizedColumns(db);
await _createNormalizedIndexes(db);
}
if (oldVersion < 11) {
await sqlite.addColumnIfMissing(db, 'history', 'sort_track', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_artist', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_album', 'TEXT');
await sqlite.addColumnIfMissing(
db,
'history',
'sort_album_artist',
'TEXT',
);
await sqlite.addColumnIfMissing(db, 'history', 'sort_genre', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_release', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_added', 'INTEGER');
await _backfillQueueSortColumns(db);
await _createQueueIndexes(db);
_log.i('Added persisted queue sort columns');
}
}
static String normalizeLookupText(String? value) =>
@@ -262,6 +287,27 @@ class HistoryDatabase {
);
}
Future<void> _createQueueIndexes(DatabaseExecutor db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_added ON history(sort_added DESC, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_track ON history(sort_track, sort_artist, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_artist ON history(sort_artist, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_album ON history(sort_album, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_genre ON history(sort_genre, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_release ON history(sort_release, sort_track, id)',
);
}
Future<void> _backfillNormalizedColumns(Database db) async {
final rows = await db.query(
'history',
@@ -322,6 +368,65 @@ class HistoryDatabase {
};
}
Map<String, dynamic> _queueSortColumns({
required String? trackName,
required String? artistName,
required String? albumName,
required String? albumArtist,
required String? genre,
required String? releaseDate,
required Object? downloadedAt,
}) {
final parsedDownloadedAt = downloadedAt is DateTime
? downloadedAt
: DateTime.tryParse(downloadedAt?.toString() ?? '');
return {
'sort_track': normalizeLookupText(trackName),
'sort_artist': normalizeLookupText(artistName),
'sort_album': normalizeLookupText(albumName),
'sort_album_artist': normalizeLookupText(
(albumArtist ?? '').trim().isEmpty ? artistName : albumArtist,
),
'sort_genre': normalizeLookupText(genre),
'sort_release': releaseDate?.trim() ?? '',
'sort_added': parsedDownloadedAt?.millisecondsSinceEpoch ?? 0,
};
}
Future<void> _backfillQueueSortColumns(Database db) async {
final rows = await db.query(
'history',
columns: [
'id',
'track_name',
'artist_name',
'album_name',
'album_artist',
'genre',
'release_date',
'downloaded_at',
],
);
final batch = db.batch();
for (final row in rows) {
batch.update(
'history',
_queueSortColumns(
trackName: row['track_name'] as String?,
artistName: row['artist_name'] as String?,
albumName: row['album_name'] as String?,
albumArtist: row['album_artist'] as String?,
genre: row['genre'] as String?,
releaseDate: row['release_date'] as String?,
downloadedAt: row['downloaded_at'],
),
where: 'id = ?',
whereArgs: [row['id']],
);
}
await batch.commit(noResult: true);
}
Future<void> _createPathKeyTable(DatabaseExecutor db) =>
sqlite.createPathKeyTable(db, 'history_path_keys');
@@ -474,6 +579,10 @@ class HistoryDatabase {
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
final downloadedAt = json['downloadedAt'];
final parsedDownloadedAt = downloadedAt is DateTime
? downloadedAt
: DateTime.tryParse(downloadedAt?.toString() ?? '');
final row = {
'id': json['id'],
'track_name': json['trackName'],
@@ -488,7 +597,9 @@ class HistoryDatabase {
'saf_file_name': json['safFileName'],
'saf_repaired': json['safRepaired'] == true ? 1 : 0,
'service': json['service'],
'downloaded_at': json['downloadedAt'],
'downloaded_at':
parsedDownloadedAt?.toUtc().toIso8601String() ??
downloadedAt?.toString(),
'isrc': json['isrc'],
'spotify_id': json['spotifyId'],
'track_number': json['trackNumber'],
@@ -507,6 +618,17 @@ class HistoryDatabase {
'label': json['label'],
'copyright': json['copyright'],
};
row.addAll(
_queueSortColumns(
trackName: json['trackName'] as String?,
artistName: json['artistName'] as String?,
albumName: json['albumName'] as String?,
albumArtist: json['albumArtist'] as String?,
genre: json['genre'] as String?,
releaseDate: json['releaseDate'] as String?,
downloadedAt: parsedDownloadedAt,
),
);
row.addAll(
_normalizedColumns(
spotifyId: json['spotifyId'] as String?,
@@ -599,7 +721,7 @@ class HistoryDatabase {
final db = await database;
final rows = await db.query(
'history',
orderBy: 'downloaded_at DESC',
orderBy: 'sort_added DESC, id DESC',
limit: limit,
offset: offset,
);
@@ -634,7 +756,7 @@ class HistoryDatabase {
'history',
where: 'match_key = ?',
whereArgs: [key],
orderBy: 'downloaded_at DESC',
orderBy: 'sort_added DESC, id DESC',
limit: 1,
);
if (rows.isEmpty) return null;
@@ -664,7 +786,7 @@ class HistoryDatabase {
FROM history h
JOIN history_path_keys hpk ON hpk.item_id = h.id
WHERE hpk.path_key IN ($placeholders)
ORDER BY h.downloaded_at DESC
ORDER BY h.sort_added DESC, h.id DESC
LIMIT 1
''', pathKeys);
if (rows.isEmpty) return null;
@@ -677,7 +799,7 @@ class HistoryDatabase {
'history',
where: 'spotify_id = ?',
whereArgs: [spotifyId],
orderBy: 'downloaded_at DESC',
orderBy: 'sort_added DESC, id DESC',
limit: 1,
);
if (rows.isEmpty) return null;
@@ -690,7 +812,7 @@ class HistoryDatabase {
'history',
where: 'isrc = ?',
whereArgs: [isrc],
orderBy: 'downloaded_at DESC',
orderBy: 'sort_added DESC, id DESC',
limit: 1,
);
if (rows.isEmpty) return null;
@@ -712,7 +834,7 @@ class HistoryDatabase {
where:
'spotify_id IN ($placeholders) OR spotify_id_norm IN ($placeholders)',
whereArgs: [...spotifyCandidates, ...normalized],
orderBy: 'downloaded_at DESC',
orderBy: 'sort_added DESC, id DESC',
limit: 1,
);
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
@@ -725,7 +847,7 @@ class HistoryDatabase {
columns: columns,
where: 'isrc_norm = ?',
whereArgs: [isrcNorm],
orderBy: 'downloaded_at DESC',
orderBy: 'sort_added DESC, id DESC',
limit: 1,
);
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
@@ -738,7 +860,7 @@ class HistoryDatabase {
columns: columns,
where: 'match_key = ?',
whereArgs: [matchKey],
orderBy: 'downloaded_at DESC',
orderBy: 'sort_added DESC, id DESC',
limit: 1,
);
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
@@ -771,7 +893,7 @@ class HistoryDatabase {
rawValues: rawValues,
destination: destination,
mapRow: _dbRowToJson,
orderBy: 'downloaded_at DESC',
orderBy: 'sort_added DESC, id DESC',
);
}
@@ -997,7 +1119,7 @@ class HistoryDatabase {
'saf_file_name',
],
where: 'file_path IS NOT NULL AND file_path != ""',
orderBy: 'downloaded_at DESC, id DESC',
orderBy: 'sort_added DESC, id DESC',
limit: limit,
offset: offset,
);
+292 -11
View File
@@ -16,7 +16,7 @@ final _log = AppLogger('LibraryDatabase');
class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
static const int schemaVersion = 9;
static const int schemaVersion = 10;
static const int audioMetadataScanVersion = 1;
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
@@ -87,7 +87,11 @@ class LibraryDatabase {
album_name_norm TEXT,
album_artist_norm TEXT,
match_key TEXT,
album_key TEXT
album_key TEXT,
search_text TEXT,
sort_genre TEXT,
sort_release TEXT,
sort_added INTEGER
)
''');
@@ -102,6 +106,7 @@ class LibraryDatabase {
'CREATE INDEX idx_library_file_path ON library(file_path)',
);
await _createNormalizedIndexes(db);
await _createQueueIndexes(db);
await _createPathKeyTable(db);
_log.i('Library database schema created with indexes');
@@ -173,6 +178,15 @@ class LibraryDatabase {
);
_log.i('Marked existing rows for one-time audio metadata rescan');
}
if (oldVersion < 10) {
await sqlite.addColumnIfMissing(db, 'library', 'search_text', 'TEXT');
await sqlite.addColumnIfMissing(db, 'library', 'sort_genre', 'TEXT');
await sqlite.addColumnIfMissing(db, 'library', 'sort_release', 'TEXT');
await sqlite.addColumnIfMissing(db, 'library', 'sort_added', 'INTEGER');
await _backfillQueueColumns(db);
await _createQueueIndexes(db);
_log.i('Added persisted queue sort/search columns');
}
}
Future<void> _createPathKeyTable(DatabaseExecutor db) =>
@@ -217,6 +231,33 @@ class LibraryDatabase {
);
}
Future<void> _createQueueIndexes(DatabaseExecutor db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_library_queue_added '
'ON library(sort_added DESC, track_name_norm, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_library_queue_track '
'ON library(track_name_norm, artist_name_norm, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_library_queue_artist '
'ON library(artist_name_norm, track_name_norm, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_library_queue_album '
'ON library(album_name_norm, track_name_norm, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_library_queue_genre '
'ON library(sort_genre, track_name_norm, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_library_queue_release '
'ON library(sort_release, track_name_norm, id)',
);
}
Future<void> _backfillNormalizedColumns(Database db) async {
final rows = await db.query(
'library',
@@ -269,7 +310,77 @@ class LibraryDatabase {
};
}
Map<String, dynamic> _queueColumns({
required String? trackName,
required String? artistName,
required String? albumName,
required String? albumArtist,
required String? genre,
required String? releaseDate,
required int? fileModTime,
required String? scannedAt,
}) {
final trackNorm = normalizeLookupText(trackName);
final artistNorm = normalizeLookupText(artistName);
final albumNorm = normalizeLookupText(albumName);
final albumArtistNorm = normalizeLookupText(
(albumArtist ?? '').trim().isEmpty ? artistName : albumArtist,
);
return {
'search_text': [
trackNorm,
artistNorm,
albumNorm,
albumArtistNorm,
].where((value) => value.isNotEmpty).join(' '),
'sort_genre': normalizeLookupText(genre),
'sort_release': releaseDate?.trim() ?? '',
'sort_added':
fileModTime ??
DateTime.tryParse(scannedAt ?? '')?.millisecondsSinceEpoch ??
0,
};
}
Future<void> _backfillQueueColumns(Database db) async {
final rows = await db.query(
'library',
columns: [
'id',
'track_name',
'artist_name',
'album_name',
'album_artist',
'genre',
'release_date',
'file_mod_time',
'scanned_at',
],
);
final batch = db.batch();
for (final row in rows) {
batch.update(
'library',
_queueColumns(
trackName: row['track_name'] as String?,
artistName: row['artist_name'] as String?,
albumName: row['album_name'] as String?,
albumArtist: row['album_artist'] as String?,
genre: row['genre'] as String?,
releaseDate: row['release_date'] as String?,
fileModTime: (row['file_mod_time'] as num?)?.toInt(),
scannedAt: row['scanned_at'] as String?,
),
where: 'id = ?',
whereArgs: [row['id']],
);
}
await batch.commit(noResult: true);
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
final fileModTime = (json['fileModTime'] as num?)?.toInt();
final scannedAt = json['scannedAt'] as String?;
final row = {
'id': json['id'],
'track_name': json['trackName'],
@@ -299,6 +410,18 @@ class LibraryDatabase {
(json['audioMetadataScanVersion'] as num?)?.toInt() ??
audioMetadataScanVersion,
};
row.addAll(
_queueColumns(
trackName: json['trackName'] as String?,
artistName: json['artistName'] as String?,
albumName: json['albumName'] as String?,
albumArtist: json['albumArtist'] as String?,
genre: json['genre'] as String?,
releaseDate: json['releaseDate'] as String?,
fileModTime: fileModTime,
scannedAt: scannedAt,
),
);
row.addAll(
_normalizedColumns(
trackName: json['trackName'] as String? ?? '',
@@ -406,6 +529,52 @@ class LibraryDatabase {
_log.i('Replaced library with ${items.length} items');
}
/// Atomically replaces the Library while consuming bounded scan batches.
/// The stream may represent tens of thousands of tracks without requiring a
/// second full list of models/maps on the Dart heap.
Future<int> replaceAllStream(
Stream<Map<String, dynamic>> items, {
int batchSize = 300,
}) async {
if (batchSize <= 0) {
throw ArgumentError.value(batchSize, 'batchSize', 'Must be positive');
}
final db = await database;
var inserted = 0;
await db.transaction((txn) async {
await txn.delete('library_path_keys');
await txn.delete('library');
var batch = txn.batch();
var pending = 0;
Future<void> flush() async {
if (pending == 0) return;
await batch.commit(noResult: true);
batch = txn.batch();
pending = 0;
}
await for (final json in items) {
final id = json['id'] as String?;
if (id == null || id.trim().isEmpty) {
throw const FormatException('Library scan row has no valid id');
}
batch.insert(
'library',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
_putPathKeysInBatch(batch, id, json['filePath'] as String?);
inserted++;
pending++;
if (pending >= batchSize) await flush();
}
await flush();
});
_log.i('Stream-replaced library with $inserted items');
return inserted;
}
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
final rows = await db.query(
@@ -441,26 +610,46 @@ class LibraryDatabase {
Future<List<Map<String, dynamic>>> getQueueTrackPage(
QueueLibraryDbQuery request,
) async {
return (await getQueueTrackPageResult(request)).rows;
}
Future<QueueLibraryDbPage> getQueueTrackPageResult(
QueueLibraryDbQuery request,
) async {
final db = await database;
await _ensureHistoryAttached(db);
final args = <Object?>[];
final unionSql = _queueTrackUnionSql(request, args);
final orderTerms = _queueTrackOrderTerms(request.sortMode);
final usesCursor =
request.cursor != null &&
request.cursor!.values.length == orderTerms.length;
final unionSql = _queueTrackUnionSql(
request,
args,
orderTerms: orderTerms,
usesCursor: usesCursor,
);
final rows = await db.rawQuery(
'''
SELECT *
FROM ($unionSql)
ORDER BY ${_queueTrackOrderBy(request.sortMode)}
LIMIT ? OFFSET ?
LIMIT ? ${usesCursor ? '' : 'OFFSET ?'}
''',
[...args, request.limit, request.offset],
[...args, request.limit, if (!usesCursor) request.offset],
);
return QueueLibraryDbPage(
rows: rows.map(_queueTrackRowToJson).toList(growable: false),
nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms),
);
return rows.map(_queueTrackRowToJson).toList(growable: false);
}
Future<QueueLibraryCounts> getQueueCounts(QueueLibraryDbQuery request) async {
final db = await database;
await _ensureHistoryAttached(db);
final fastCounts = await _getUnfilteredQueueCounts(db, request);
if (fastCounts != null) return fastCounts;
final parts = <String>[];
final args = <Object?>[];
@@ -539,23 +728,115 @@ class LibraryDatabase {
);
}
/// The default Library badges do not need a row-by-row join against album
/// counts. Aggregate the covering album-key indexes directly and reserve the
/// more expensive filtered query for active search/quality/metadata filters.
Future<QueueLibraryCounts?> _getUnfilteredQueueCounts(
Database db,
QueueLibraryDbQuery request,
) async {
if (normalizeLookupText(request.searchQuery).isNotEmpty ||
request.quality != null ||
request.format != null ||
request.metadata != null) {
return null;
}
final source = request.source;
if (source != null && source != 'downloaded' && source != 'local') {
return null;
}
final parts = <String>[];
if (source != 'local') {
parts.add('''
SELECT
COALESCE(SUM(track_count), 0) AS all_count,
COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count,
COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
FROM (
SELECT album_key, COUNT(*) AS track_count
FROM history_db.history
GROUP BY album_key
)
''');
}
if (request.includeLocal && source != 'downloaded') {
parts.add('''
SELECT
COALESCE(SUM(track_count), 0) AS all_count,
COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count,
COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
FROM (
SELECT l.album_key, COUNT(*) AS track_count
FROM library l
WHERE NOT EXISTS (
SELECT 1
FROM library_path_keys lpk
JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key
WHERE lpk.item_id = l.id
)
GROUP BY l.album_key
)
''');
}
if (parts.isEmpty) {
return const QueueLibraryCounts(
allTrackCount: 0,
albumCount: 0,
singleTrackCount: 0,
);
}
final rows = await db.rawQuery('''
SELECT
COALESCE(SUM(all_count), 0) AS all_count,
COALESCE(SUM(album_count), 0) AS album_count,
COALESCE(SUM(single_count), 0) AS single_count
FROM (${parts.join(' UNION ALL ')})
''');
final row = rows.isEmpty ? const <String, Object?>{} : rows.first;
return QueueLibraryCounts(
allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0,
albumCount: (row['album_count'] as num?)?.toInt() ?? 0,
singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0,
);
}
Future<List<Map<String, dynamic>>> getQueueAlbumPage(
QueueLibraryDbQuery request,
) async {
return (await getQueueAlbumPageResult(request)).rows;
}
Future<QueueLibraryDbPage> getQueueAlbumPageResult(
QueueLibraryDbQuery request,
) async {
final db = await database;
await _ensureHistoryAttached(db);
final args = <Object?>[];
final unionSql = _queueAlbumUnionSql(request, args);
final orderTerms = _queueAlbumOrderTerms(request.sortMode);
final usesCursor =
request.cursor != null &&
request.cursor!.values.length == orderTerms.length;
final unionSql = _queueAlbumUnionSql(
request,
args,
orderTerms: orderTerms,
usesCursor: usesCursor,
);
final rows = await db.rawQuery(
'''
SELECT *
FROM ($unionSql)
ORDER BY ${_queueAlbumOrderBy(request.sortMode)}
LIMIT ? OFFSET ?
LIMIT ? ${usesCursor ? '' : 'OFFSET ?'}
''',
[...args, request.limit, request.offset],
[...args, request.limit, if (!usesCursor) request.offset],
);
return QueueLibraryDbPage(
rows: rows.toList(growable: false),
nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms),
);
return rows.toList(growable: false);
}
Future<List<Map<String, dynamic>>> getQueueLocalAlbumTracks(
@@ -1081,7 +1362,7 @@ class LibraryDatabase {
for (final entry in fileModTimes.entries) {
batch.update(
'library',
{'file_mod_time': entry.value},
{'file_mod_time': entry.value, 'sort_added': entry.value},
where: 'file_path = ?',
whereArgs: [entry.key],
);
+36
View File
@@ -232,6 +232,7 @@ class QueueLibraryDbQuery {
final String? metadata;
final String sortMode;
final bool includeLocal;
final QueueLibraryDbCursor? cursor;
const QueueLibraryDbQuery({
this.limit = 100,
@@ -244,9 +245,44 @@ class QueueLibraryDbQuery {
this.metadata,
this.sortMode = 'latest',
this.includeLocal = true,
this.cursor,
});
}
/// Opaque seek cursor for queue Library pagination.
///
/// Values follow the active SQL order (including its unique tie-breaker), so
/// later pages can seek from the last row instead of making SQLite discard an
/// ever-growing OFFSET prefix.
class QueueLibraryDbCursor {
final List<Object> values;
const QueueLibraryDbCursor(this.values);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! QueueLibraryDbCursor ||
other.values.length != values.length) {
return false;
}
for (var i = 0; i < values.length; i++) {
if (values[i] != other.values[i]) return false;
}
return true;
}
@override
int get hashCode => Object.hashAll(values);
}
class QueueLibraryDbPage {
final List<Map<String, dynamic>> rows;
final QueueLibraryDbCursor? nextCursor;
const QueueLibraryDbPage({required this.rows, required this.nextCursor});
}
class QueueLibraryCounts {
final int allTrackCount;
final int albumCount;
+299 -60
View File
@@ -2,8 +2,20 @@ part of 'library_database.dart';
// SQL builders for the queue tab's history+local union queries.
class _QueueOrderTerm {
final String column;
final bool descending;
const _QueueOrderTerm(this.column, {this.descending = false});
}
extension _LibraryDbQueueSql on LibraryDatabase {
String _queueTrackUnionSql(QueueLibraryDbQuery request, List<Object?> args) {
String _queueTrackUnionSql(
QueueLibraryDbQuery request,
List<Object?> args, {
required List<_QueueOrderTerm> orderTerms,
required bool usesCursor,
}) {
final parts = <String>[];
if (request.source != 'local') {
final where = <String>[];
@@ -18,7 +30,8 @@ extension _LibraryDbQueueSql on LibraryDatabase {
)
''');
}
parts.add('''
final selectSql =
'''
SELECT
'downloaded' AS queue_source,
'dl_' || h.id AS unified_id,
@@ -56,15 +69,24 @@ extension _LibraryDbQueueSql on LibraryDatabase {
NULL AS file_mod_time,
h.bitrate,
h.format,
LOWER(h.track_name) AS sort_track,
LOWER(h.artist_name) AS sort_artist,
LOWER(h.album_name) AS sort_album,
LOWER(COALESCE(h.genre, '')) AS sort_genre,
h.release_date AS sort_release,
CAST(strftime('%s', h.downloaded_at) AS INTEGER) * 1000 AS sort_added
h.sort_track,
h.sort_artist,
h.sort_album,
h.sort_genre,
h.sort_release,
h.sort_added
FROM history_db.history h
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
''');
''';
parts.add(
_boundedQueuePart(
selectSql,
request,
args,
orderTerms,
usesCursor: usesCursor,
),
);
}
if (request.includeLocal && request.source != 'downloaded') {
@@ -95,7 +117,8 @@ extension _LibraryDbQueueSql on LibraryDatabase {
)
''');
}
parts.add('''
final selectSql =
'''
SELECT
'local' AS queue_source,
'local_' || l.id AS unified_id,
@@ -136,12 +159,21 @@ extension _LibraryDbQueueSql on LibraryDatabase {
l.track_name_norm AS sort_track,
l.artist_name_norm AS sort_artist,
l.album_name_norm AS sort_album,
LOWER(COALESCE(l.genre, '')) AS sort_genre,
l.release_date AS sort_release,
COALESCE(l.file_mod_time, CAST(strftime('%s', l.scanned_at) AS INTEGER) * 1000) AS sort_added
l.sort_genre,
l.sort_release,
l.sort_added
FROM library l
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
''');
''';
parts.add(
_boundedQueuePart(
selectSql,
request,
args,
orderTerms,
usesCursor: usesCursor,
),
);
}
if (parts.isEmpty) {
@@ -195,12 +227,18 @@ extension _LibraryDbQueueSql on LibraryDatabase {
return parts.join(' UNION ALL ');
}
String _queueAlbumUnionSql(QueueLibraryDbQuery request, List<Object?> args) {
String _queueAlbumUnionSql(
QueueLibraryDbQuery request,
List<Object?> args, {
required List<_QueueOrderTerm> orderTerms,
required bool usesCursor,
}) {
final parts = <String>[];
if (request.source != 'local') {
final where = <String>[];
_appendQueueHistoryFilters(where, args, request);
parts.add('''
final selectSql =
'''
SELECT
'downloaded' AS queue_source,
c.album_key,
@@ -211,16 +249,16 @@ extension _LibraryDbQueueSql on LibraryDatabase {
MAX(h.file_path) AS sample_file_path,
COUNT(*) AS track_count,
c.latest_added AS sort_added,
MIN(LOWER(COALESCE(h.album_name, ''))) AS sort_album,
MIN(LOWER(COALESCE(h.album_artist, h.artist_name, ''))) AS sort_artist,
MAX(h.release_date) AS sort_release,
MAX(LOWER(COALESCE(h.genre, ''))) AS sort_genre
MIN(COALESCE(h.sort_album, '')) AS sort_album,
MIN(COALESCE(h.sort_album_artist, '')) AS sort_artist,
COALESCE(MAX(h.release_date), '') AS sort_release,
COALESCE(MAX(h.sort_genre), '') AS sort_genre
FROM history_db.history h
JOIN (
SELECT
album_key,
COUNT(*) AS track_count,
MAX(CAST(strftime('%s', downloaded_at) AS INTEGER) * 1000) AS latest_added
MAX(COALESCE(sort_added, 0)) AS latest_added
FROM history_db.history
GROUP BY album_key
HAVING COUNT(*) > 1
@@ -228,7 +266,16 @@ extension _LibraryDbQueueSql on LibraryDatabase {
ON c.album_key = h.album_key
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
GROUP BY c.album_key
''');
''';
parts.add(
_boundedQueuePart(
selectSql,
request,
args,
orderTerms,
usesCursor: usesCursor,
),
);
}
if (request.includeLocal && request.source != 'downloaded') {
@@ -243,7 +290,8 @@ extension _LibraryDbQueueSql on LibraryDatabase {
''',
];
_appendQueueLocalFilters(where, args, request);
parts.add('''
final selectSql =
'''
SELECT
'local' AS queue_source,
c.album_key,
@@ -256,14 +304,14 @@ extension _LibraryDbQueueSql on LibraryDatabase {
c.latest_added AS sort_added,
MIN(l.album_name_norm) AS sort_album,
MIN(l.album_artist_norm) AS sort_artist,
MAX(l.release_date) AS sort_release,
MAX(LOWER(COALESCE(l.genre, ''))) AS sort_genre
COALESCE(MAX(l.release_date), '') AS sort_release,
COALESCE(MAX(l.sort_genre), '') AS sort_genre
FROM library l
JOIN (
SELECT
album_key,
COUNT(*) AS track_count,
MAX(COALESCE(file_mod_time, CAST(strftime('%s', scanned_at) AS INTEGER) * 1000)) AS latest_added
MAX(COALESCE(sort_added, 0)) AS latest_added
FROM library
WHERE NOT EXISTS (
SELECT 1
@@ -276,7 +324,16 @@ extension _LibraryDbQueueSql on LibraryDatabase {
) c ON c.album_key = l.album_key
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
GROUP BY c.album_key
''');
''';
parts.add(
_boundedQueuePart(
selectSql,
request,
args,
orderTerms,
usesCursor: usesCursor,
),
);
}
if (parts.isEmpty) {
@@ -339,15 +396,8 @@ extension _LibraryDbQueueSql on LibraryDatabase {
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final like = '%${_escapeLikePattern(query)}%';
where.add('''
(
l.track_name_norm LIKE ? ESCAPE '\\' OR
l.artist_name_norm LIKE ? ESCAPE '\\' OR
l.album_name_norm LIKE ? ESCAPE '\\' OR
l.album_artist_norm LIKE ? ESCAPE '\\'
)
''');
args.addAll([like, like, like, like]);
where.add("l.search_text LIKE ? ESCAPE '\\'");
args.add(like);
}
_appendQueueCommonFilters(
where,
@@ -472,37 +522,226 @@ extension _LibraryDbQueueSql on LibraryDatabase {
}
String _queueTrackOrderBy(String sortMode) {
return switch (sortMode) {
'oldest' => 'sort_added ASC, sort_track ASC',
'a-z' => 'sort_track ASC, sort_artist ASC',
'z-a' => 'sort_track DESC, sort_artist DESC',
'artist-asc' => 'sort_artist ASC, sort_track ASC',
'artist-desc' => 'sort_artist DESC, sort_track ASC',
'album-asc' => 'sort_album ASC, sort_track ASC',
'album-desc' => 'sort_album DESC, sort_track ASC',
'release-oldest' => 'sort_release ASC, sort_track ASC',
'release-newest' => 'sort_release DESC, sort_track ASC',
'genre-asc' => 'sort_genre ASC, sort_track ASC',
'genre-desc' => 'sort_genre DESC, sort_track ASC',
_ => 'sort_added DESC, sort_track ASC',
};
return _queueOrderBy(_queueTrackOrderTerms(sortMode));
}
String _queueAlbumOrderBy(String sortMode) {
return _queueOrderBy(_queueAlbumOrderTerms(sortMode));
}
String _boundedQueuePart(
String selectSql,
QueueLibraryDbQuery request,
List<Object?> args,
List<_QueueOrderTerm> orderTerms, {
required bool usesCursor,
}) {
final cursorPredicate = usesCursor
? _queueCursorPredicate(request.cursor, orderTerms, args)
: '';
final branchLimit = usesCursor
? request.limit
: request.limit + request.offset;
args.add(branchLimit);
final branchOrder = orderTerms
.where((term) => term.column != 'queue_source')
.toList(growable: false);
return '''
SELECT * FROM (
SELECT *
FROM ($selectSql)
${cursorPredicate.isEmpty ? '' : 'WHERE $cursorPredicate'}
ORDER BY ${_queueOrderBy(branchOrder)}
LIMIT ?
)
''';
}
List<_QueueOrderTerm> _queueTrackOrderTerms(String sortMode) {
return switch (sortMode) {
'oldest' => 'sort_added ASC, sort_album ASC',
'a-z' || 'album-asc' => 'sort_album ASC, sort_artist ASC',
'z-a' || 'album-desc' => 'sort_album DESC, sort_artist DESC',
'artist-asc' => 'sort_artist ASC, sort_album ASC',
'artist-desc' => 'sort_artist DESC, sort_album ASC',
'release-oldest' => 'sort_release ASC, sort_album ASC',
'release-newest' => 'sort_release DESC, sort_album ASC',
'genre-asc' => 'sort_genre ASC, sort_album ASC',
'genre-desc' => 'sort_genre DESC, sort_album ASC',
_ => 'sort_added DESC, sort_album ASC',
'oldest' => const [
_QueueOrderTerm('sort_added'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'a-z' => const [
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('sort_artist'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'z-a' => const [
_QueueOrderTerm('sort_track', descending: true),
_QueueOrderTerm('sort_artist', descending: true),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'artist-asc' => const [
_QueueOrderTerm('sort_artist'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'artist-desc' => const [
_QueueOrderTerm('sort_artist', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'album-asc' => const [
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'album-desc' => const [
_QueueOrderTerm('sort_album', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'release-oldest' => const [
_QueueOrderTerm('sort_release'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'release-newest' => const [
_QueueOrderTerm('sort_release', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'genre-asc' => const [
_QueueOrderTerm('sort_genre'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'genre-desc' => const [
_QueueOrderTerm('sort_genre', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
_ => const [
_QueueOrderTerm('sort_added', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
};
}
List<_QueueOrderTerm> _queueAlbumOrderTerms(String sortMode) {
return switch (sortMode) {
'oldest' => const [
_QueueOrderTerm('sort_added'),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'a-z' || 'album-asc' => const [
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('sort_artist'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'z-a' || 'album-desc' => const [
_QueueOrderTerm('sort_album', descending: true),
_QueueOrderTerm('sort_artist', descending: true),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'artist-asc' => const [
_QueueOrderTerm('sort_artist'),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'artist-desc' => const [
_QueueOrderTerm('sort_artist', descending: true),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'release-oldest' => const [
_QueueOrderTerm('sort_release'),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'release-newest' => const [
_QueueOrderTerm('sort_release', descending: true),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'genre-asc' => const [
_QueueOrderTerm('sort_genre'),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'genre-desc' => const [
_QueueOrderTerm('sort_genre', descending: true),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
_ => const [
_QueueOrderTerm('sort_added', descending: true),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
};
}
String _queueOrderBy(List<_QueueOrderTerm> terms) => terms
.map((term) => '${term.column} ${term.descending ? 'DESC' : 'ASC'}')
.join(', ');
String _queueCursorPredicate(
QueueLibraryDbCursor? cursor,
List<_QueueOrderTerm> terms,
List<Object?> args,
) {
if (cursor == null || cursor.values.length != terms.length) return '';
final clauses = <String>[];
final first = terms.first;
final coarseOperator = first.descending ? '<=' : '>=';
args.add(cursor.values.first);
for (var i = 0; i < terms.length; i++) {
final comparisons = <String>[];
for (var j = 0; j < i; j++) {
comparisons.add('${terms[j].column} = ?');
args.add(cursor.values[j]);
}
comparisons.add(
'${terms[i].column} ${terms[i].descending ? '<' : '>'} ?',
);
args.add(cursor.values[i]);
clauses.add('(${comparisons.join(' AND ')})');
}
return '(${first.column} $coarseOperator ?) AND (${clauses.join(' OR ')})';
}
QueueLibraryDbCursor? _queueCursorFromRow(
Map<String, dynamic>? row,
List<_QueueOrderTerm> terms,
) {
if (row == null) return null;
final values = <Object>[];
for (final term in terms) {
final value = row[term.column];
if (value is! Object) return null;
values.add(value);
}
return QueueLibraryDbCursor(List<Object>.unmodifiable(values));
}
Map<String, dynamic> _queueTrackRowToJson(Map<String, dynamic> row) {
final source = row['queue_source'] as String? ?? '';
if (source == 'local') {
+202 -10
View File
@@ -20,6 +20,38 @@ bool isForegroundServiceStartNotAllowed(Object error) {
}
Object? _decodeJsonInBackground(String json) => jsonDecode(json);
String _encodeJsonInBackground(Object? value) => jsonEncode(value);
class LibraryScanNDJSONFile {
final File file;
final int expectedCount;
const LibraryScanNDJSONFile({
required this.file,
required this.expectedCount,
});
Stream<Map<String, dynamic>> rows() async* {
await for (final line
in file
.openRead()
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (line.trim().isEmpty) continue;
final decoded = jsonDecode(line);
if (decoded is! Map) {
throw const FormatException('Library scan NDJSON row is not an object');
}
yield Map<String, dynamic>.from(decoded);
}
}
Future<void> delete() async {
try {
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
class ExtensionSessionGrantEvent {
final String extensionId;
@@ -40,6 +72,13 @@ class IosPickedDirectory {
const IosPickedDirectory({required this.path, required this.bookmark});
}
class IosSecurityScopedAccess {
final String path;
final String token;
const IosSecurityScopedAccess({required this.path, required this.token});
}
class InstallationState {
final bool markerExisted;
final bool markerCreated;
@@ -113,6 +152,7 @@ class PlatformBridge {
static const _urlHandleCacheTtl = Duration(minutes: 5);
static const _customSearchCacheTtl = Duration(minutes: 2);
static const _bridgeCacheMaxEntries = 256;
static const _lookupCachePersistDebounce = Duration(milliseconds: 500);
static const _metadataPersistentCacheKey = 'bridge_metadata_lookup_cache_v1';
static const _downloadProgressEvents = EventChannel(
'com.zarz.spotiflac/download_progress_stream',
@@ -132,6 +172,9 @@ class PlatformBridge {
_homeFeedInFlight = {};
static Future<void>? _persistentLookupCacheLoadFuture;
static int _lookupCacheGeneration = 0;
static Timer? _lookupCachePersistTimer;
static Future<void>? _lookupCachePersistInFlight;
static bool _lookupCachePersistDirty = false;
static int _extensionRequestSequence = 0;
static final StreamController<ExtensionSessionGrantEvent>
_extensionSessionGrantEvents =
@@ -259,8 +302,10 @@ class PlatformBridge {
value: _copyStringMap(value),
expiresAt: DateTime.now().add(ttl),
);
unawaited(
_persistLookupCache(cache, persistentCacheKey, _lookupCacheGeneration),
_scheduleLookupCachePersist(
cache,
persistentCacheKey,
_lookupCacheGeneration,
);
}
@@ -383,14 +428,69 @@ class PlatformBridge {
'value': entry.value.value,
},
};
final encoded = data.length >= 32
? await compute(_encodeJsonInBackground, data)
: jsonEncode(data);
if (generation != _lookupCacheGeneration) return;
final prefs = await SharedPreferences.getInstance();
if (generation != _lookupCacheGeneration) return;
await prefs.setString(prefsKey, jsonEncode(data));
await prefs.setString(prefsKey, encoded);
} catch (e) {
_log.w('Failed to persist bridge lookup cache: $e');
}
}
static void _scheduleLookupCachePersist(
Map<String, _BridgeCacheEntry> cache,
String prefsKey,
int generation,
) {
_lookupCachePersistDirty = true;
if (_lookupCachePersistInFlight != null) return;
_lookupCachePersistTimer?.cancel();
_lookupCachePersistTimer = Timer(_lookupCachePersistDebounce, () {
_lookupCachePersistTimer = null;
unawaited(_flushLookupCachePersist(cache, prefsKey, generation));
});
}
static Future<void> _flushLookupCachePersist(
Map<String, _BridgeCacheEntry> cache,
String prefsKey,
int generation,
) {
final active = _lookupCachePersistInFlight;
if (active != null) {
_lookupCachePersistDirty = true;
return active;
}
late final Future<void> flush;
flush =
() async {
do {
_lookupCachePersistDirty = false;
if (generation != _lookupCacheGeneration) return;
await _persistLookupCache(cache, prefsKey, generation);
} while (_lookupCachePersistDirty &&
generation == _lookupCacheGeneration);
}().whenComplete(() {
if (identical(_lookupCachePersistInFlight, flush)) {
_lookupCachePersistInFlight = null;
}
});
_lookupCachePersistInFlight = flush;
return flush;
}
static Future<void> _cancelLookupCachePersistence() async {
_lookupCachePersistTimer?.cancel();
_lookupCachePersistTimer = null;
_lookupCachePersistDirty = false;
final active = _lookupCachePersistInFlight;
if (active != null) await active;
}
static Future<void> _clearPersistentLookupCaches() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -404,6 +504,7 @@ class PlatformBridge {
static Future<void> _clearLookupCaches() async {
_lookupCacheGeneration++;
await _cancelLookupCachePersistence();
_persistentLookupCacheLoadFuture = null;
_metadataCache.clear();
_urlHandleCache.clear();
@@ -1839,6 +1940,17 @@ class PlatformBridge {
return _decodeMapListResultAsync(result, 'scanLibraryFolder');
}
static Future<LibraryScanNDJSONFile> scanLibraryFolderToNDJSONFile(
String folderPath, {
bool Function()? isCancelled,
}) {
return _scanToNDJSONFile(
method: 'scanLibraryFolderToNDJSONFile',
arguments: {'folder_path': folderPath},
isCancelled: isCancelled,
);
}
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
String folderPath,
Map<String, int> existingFiles,
@@ -1878,6 +1990,67 @@ class PlatformBridge {
return _decodeMapListResultAsync(result, 'scanSafTree');
}
static Future<LibraryScanNDJSONFile> scanSafTreeToNDJSONFile(
String treeUri, {
bool Function()? isCancelled,
}) {
return _scanToNDJSONFile(
method: 'scanSafTreeToNDJSONFile',
arguments: {'tree_uri': treeUri},
isCancelled: isCancelled,
);
}
static Future<LibraryScanNDJSONFile> _scanToNDJSONFile({
required String method,
required Map<String, dynamic> arguments,
bool Function()? isCancelled,
}) async {
final tempDir = await getTemporaryDirectory();
final output = File(
'${tempDir.path}${Platform.pathSeparator}'
'library_scan_${DateTime.now().microsecondsSinceEpoch}.ndjson',
);
try {
if (isCancelled?.call() == true) {
throw StateError('Library scan cancelled before native scan');
}
final result = await _channel.invokeMethod(method, {
...arguments,
'output_path': output.path,
});
if (result is! Map) {
throw FormatException('$method returned ${result.runtimeType}');
}
if (result['cancelled'] == true) {
throw FormatException('$method returned a cancelled partial scan');
}
final pathValue = result['path'];
final countValue = result['count'];
if (pathValue is! String || pathValue.trim().isEmpty) {
throw FormatException('$method returned an invalid output path');
}
if (countValue is! num ||
!countValue.isFinite ||
countValue < 0 ||
countValue != countValue.toInt()) {
throw FormatException('$method returned an invalid row count');
}
final path = pathValue;
final count = countValue.toInt();
final file = File(path);
if (!await file.exists()) {
throw FormatException('$method did not create its output file');
}
return LibraryScanNDJSONFile(file: file, expectedCount: count);
} catch (_) {
try {
if (await output.exists()) await output.delete();
} catch (_) {}
rethrow;
}
}
static Future<Map<String, dynamic>> scanSafTreeIncremental(
String treeUri,
Map<String, int> existingFiles,
@@ -2103,24 +2276,43 @@ class PlatformBridge {
}
/// Resolve a base64-encoded iOS security-scoped bookmark and start accessing
/// the resource. Returns the resolved filesystem path.
/// The resource stays accessed until [stopAccessingIosBookmark] is called.
static Future<String?> startAccessingIosBookmark(String bookmark) async {
/// the resource. The returned lease must be passed to
/// [stopAccessingIosBookmark] by the operation that acquired it.
static Future<IosSecurityScopedAccess?> startAccessingIosBookmark(
String bookmark,
) async {
try {
final result = await _channel.invokeMethod('startAccessingIosBookmark', {
'bookmark': bookmark,
});
return result as String?;
if (result is! Map) {
throw FormatException(
'startAccessingIosBookmark returned ${result.runtimeType}',
);
}
final path = result['path'];
final token = result['token'];
if (path is! String ||
path.trim().isEmpty ||
token is! String ||
token.trim().isEmpty) {
throw const FormatException('Invalid iOS bookmark access lease');
}
return IosSecurityScopedAccess(path: path, token: token);
} catch (e) {
_log.w('Failed to start accessing iOS bookmark: $e');
return null;
}
}
/// Stop accessing the currently active iOS security-scoped resource.
static Future<void> stopAccessingIosBookmark() async {
/// Releases exactly the security-scoped lease acquired by the caller.
static Future<void> stopAccessingIosBookmark(
IosSecurityScopedAccess access,
) async {
try {
await _channel.invokeMethod('stopAccessingIosBookmark');
await _channel.invokeMethod('stopAccessingIosBookmark', {
'token': access.token,
});
} catch (e) {
_log.w('Failed to stop accessing iOS bookmark: $e');
}