perf(library): page missing-file cleanup with safe SAF batch checks

This commit is contained in:
zarzet
2026-09-06 02:34:49 +07:00
parent 90d3a5e3de
commit 62ef538690
8 changed files with 456 additions and 54 deletions
+82
View File
@@ -0,0 +1,82 @@
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/utils/file_access.dart';
/// Pages by stable ID (not OFFSET, since rows are removed during traversal).
/// A high-water mark bounds the run if a concurrent scan adds new rows.
Future<int> cleanupMissingLibraryRows(
Database db, {
String? sourceId,
Future<bool> Function()? canDelete,
Future<Map<String, bool?>> Function(List<String>) checkPaths =
fileExistenceByPath,
}) async {
const pageSize = 256;
final sourceWhere = sourceId == null ? null : 'source_id = ?';
final sourceArgs = sourceId == null ? null : <Object?>[sourceId];
final last = await db.query(
'library',
columns: ['id'],
where: sourceWhere,
whereArgs: sourceArgs,
orderBy: 'id DESC',
limit: 1,
);
if (last.isEmpty) return 0;
final upperId = last.single['id'] as String;
String? cursor;
var removed = 0;
while (true) {
final rows = await db.query(
'library',
columns: ['id', 'file_path'],
where: [
?sourceWhere,
'id <= ?',
if (cursor != null) 'id > ?',
].join(' AND '),
whereArgs: [?sourceId, upperId, ?cursor],
orderBy: 'id ASC',
limit: pageSize,
);
if (rows.isEmpty) break;
cursor = rows.last['id'] as String;
final paths = rows.map((row) => row['file_path'] as String).toList();
Map<String, bool?> checks;
try {
checks = await checkPaths(paths);
} catch (_) {
continue; // No deletion on a failed batch.
}
final missing = rows
.where((row) => checks[row['file_path']] == false)
.toList();
if (missing.isEmpty) continue;
if (canDelete != null) {
try {
if (!await canDelete()) break;
} catch (_) {
break;
}
}
// Compare the original path as well: a scan may repair it while I/O runs.
final conditions = List.filled(
missing.length,
'(id = ? AND file_path = ?)',
).join(' OR ');
final where =
'($conditions)${sourceWhere == null ? '' : ' AND $sourceWhere'}';
final args = <Object?>[
for (final row in missing) ...[row['id'], row['file_path']],
?sourceId,
];
removed += await db.transaction((txn) async {
await txn.rawDelete(
'DELETE FROM library_path_keys WHERE item_id IN '
'(SELECT id FROM library WHERE $where)',
args,
);
return txn.rawDelete('DELETE FROM library WHERE $where', args);
});
}
return removed;
}
+9 -49
View File
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/audio_format_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_cleanup.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
part 'library_database_models.dart';
@@ -1944,58 +1945,17 @@ class LibraryDatabase {
});
}
Future<int> cleanupMissingFiles({String? sourceId}) async {
Future<int> cleanupMissingFiles({
String? sourceId,
Future<bool> Function()? canDelete,
}) async {
final db = await database;
final rows = await db.query(
'library',
columns: ['id', 'file_path'],
where: sourceId == null ? null : 'source_id = ?',
whereArgs: sourceId == null ? null : [sourceId],
final removed = await cleanupMissingLibraryRows(
db,
sourceId: sourceId,
canDelete: canDelete,
);
final missingIds = <String>[];
const checkChunkSize = 16;
for (var i = 0; i < rows.length; i += checkChunkSize) {
final end = (i + checkChunkSize < rows.length)
? i + checkChunkSize
: rows.length;
final chunk = rows.sublist(i, end);
final checks = await Future.wait<MapEntry<String, bool>>(
chunk.map((row) async {
final id = row['id'] as String;
final filePath = row['file_path'] as String;
return MapEntry(id, await fileExists(filePath));
}),
);
for (final check in checks) {
if (!check.value) {
missingIds.add(check.key);
}
}
}
if (missingIds.isEmpty) {
return 0;
}
var removed = 0;
const deleteChunkSize = 500;
for (var i = 0; i < missingIds.length; i += deleteChunkSize) {
final end = (i + deleteChunkSize < missingIds.length)
? i + deleteChunkSize
: missingIds.length;
final idChunk = missingIds.sublist(i, end);
final placeholders = List.filled(idChunk.length, '?').join(',');
await db.rawDelete(
'DELETE FROM library_path_keys WHERE item_id IN ($placeholders)',
idChunk,
);
removed += await db.rawDelete(
'DELETE FROM library WHERE id IN ($placeholders)',
idChunk,
);
}
if (removed > 0) {
_log.i('Cleaned up $removed missing files from library');
}
+17
View File
@@ -750,6 +750,23 @@ class PlatformBridge {
return result as bool;
}
/// Exact-path checks. Only explicit `missing` results authorize cleanup;
/// absent responses and permission/provider failures remain unknown.
static Future<Map<String, bool?>> safExistsBatch(List<String> uris) async {
if (uris.isEmpty) return const {};
final result = await _invokeMap('safExistsBatch', {
'uris_json': jsonEncode(uris),
});
return {
for (final uri in uris)
uri: switch (result[uri]) {
'found' => true,
'missing' => false,
_ => null,
},
};
}
/// Whether the persisted SAF grant for [treeUri] is still usable: the
/// permission is present in the system's persisted list and the tree
/// document still exists and is writable. Returns true on channel errors