mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-14 05:49:02 +02:00
perf(library): page missing-file cleanup with safe SAF batch checks
This commit is contained in:
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/progress_stream_poller.dart';
|
||||
|
||||
@@ -1143,7 +1144,20 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
if (securityAccess == null) continue;
|
||||
}
|
||||
removed += await _db.cleanupMissingFiles(sourceId: source.id);
|
||||
removed += await _db.cleanupMissingFiles(
|
||||
sourceId: source.id,
|
||||
canDelete: () async {
|
||||
if (isContentUri(source.path)) {
|
||||
return PlatformBridge.isSafTreeAccessible(source.path);
|
||||
}
|
||||
// Recheck access after the file probes, before deleting a page.
|
||||
// An empty but readable source is valid; an offline one throws.
|
||||
await Directory(
|
||||
securityAccess?.path ?? source.path,
|
||||
).list(followLinks: false).take(1).drain<void>();
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (securityAccess != null) {
|
||||
await PlatformBridge.stopAccessingIosBookmark(securityAccess);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -296,6 +296,56 @@ Future<bool> fileExists(String? path) async {
|
||||
return File(realPath).exists();
|
||||
}
|
||||
|
||||
/// Bounded, tri-state checks for destructive library cleanup. Unknown results
|
||||
/// retain their database rows. CUE tracks are checked against the backing file.
|
||||
Future<Map<String, bool?>> fileExistenceByPath(List<String> paths) async {
|
||||
final realPaths = {for (final path in paths) path: stripCueTrackSuffix(path)};
|
||||
final results = <String, bool?>{};
|
||||
final unique = realPaths.values.toSet();
|
||||
final saf = unique.where(isContentUri).toList(growable: false);
|
||||
const batchSize = 64;
|
||||
for (var start = 0; start < saf.length; start += batchSize) {
|
||||
final end = start + batchSize < saf.length ? start + batchSize : saf.length;
|
||||
final batch = saf.sublist(start, end);
|
||||
try {
|
||||
results.addAll(await PlatformBridge.safExistsBatch(batch));
|
||||
} catch (_) {
|
||||
// Missing native method on an older build is also inconclusive.
|
||||
}
|
||||
}
|
||||
final local = unique.where((path) => !isContentUri(path)).toList();
|
||||
const concurrency = 16;
|
||||
for (var start = 0; start < local.length; start += concurrency) {
|
||||
final end = start + concurrency < local.length
|
||||
? start + concurrency
|
||||
: local.length;
|
||||
await Future.wait(
|
||||
local.sublist(start, end).map((path) async {
|
||||
if (path.isEmpty) return;
|
||||
try {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type != FileSystemEntityType.notFound) {
|
||||
results[path] = true;
|
||||
} else {
|
||||
// Dart's stat also returns notFound for permission errors. Opening
|
||||
// exposes the OS error without reading the audio file's contents.
|
||||
final handle = await File(path).open();
|
||||
await handle.close();
|
||||
results[path] = true;
|
||||
}
|
||||
} on FileSystemException catch (error) {
|
||||
final code = error.osError?.errorCode;
|
||||
final absent = code == 2 || code == (Platform.isWindows ? 3 : 20);
|
||||
results[path] = absent ? false : null;
|
||||
} catch (_) {
|
||||
results[path] = null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
return {for (final item in realPaths.entries) item.key: results[item.value]};
|
||||
}
|
||||
|
||||
/// Deletes [path] and reports whether the file is confirmed absent afterward.
|
||||
///
|
||||
/// SAF providers are allowed to reject a delete request by returning `false`.
|
||||
|
||||
Reference in New Issue
Block a user