mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 12:52:40 +02:00
perf(history): batch SAF orphan inspection #511
This commit is contained in:
@@ -59,7 +59,6 @@ String? resolvePersistedHistoryQuality({
|
||||
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const int _initialHistoryLoadLimit = 100;
|
||||
static const int _safRepairBatchSize = 20;
|
||||
static const int _safRepairMaxPerLaunch = 60;
|
||||
static const int _orphanCleanupMaxPerLaunch = 80;
|
||||
static const int _audioMetadataBackfillMaxPerLaunch = 24;
|
||||
@@ -697,80 +696,36 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
final replacementFileNames = <String, String>{};
|
||||
final replacementRelativeDirs = <String, String>{};
|
||||
final pathById = <String, String>{};
|
||||
final regularEntries = <Map<String, dynamic>>[];
|
||||
final safEntries = <Map<String, dynamic>>[];
|
||||
|
||||
for (final entry in entries) {
|
||||
final id = entry['id'] as String;
|
||||
final filePath = (entry['file_path'] as String? ?? '').trim();
|
||||
if (filePath.isEmpty) continue;
|
||||
pathById[id] = filePath;
|
||||
if (entry['storage_mode'] == 'saf') {
|
||||
safEntries.add(entry);
|
||||
} else {
|
||||
regularEntries.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const checkChunkSize = 16;
|
||||
|
||||
for (var i = 0; i < entries.length; i += checkChunkSize) {
|
||||
final end = (i + checkChunkSize < entries.length)
|
||||
for (var i = 0; i < regularEntries.length; i += checkChunkSize) {
|
||||
final end = (i + checkChunkSize < regularEntries.length)
|
||||
? i + checkChunkSize
|
||||
: entries.length;
|
||||
final chunk = entries.sublist(i, end);
|
||||
: regularEntries.length;
|
||||
final chunk = regularEntries.sublist(i, end);
|
||||
|
||||
final checks = await Future.wait<MapEntry<String, bool?>?>(
|
||||
chunk.map((entry) async {
|
||||
final id = entry['id'] as String;
|
||||
final filePath = entry['file_path'] as String?;
|
||||
if (filePath == null || filePath.isEmpty) return null;
|
||||
pathById[id] = filePath;
|
||||
final filePath = entry['file_path'] as String;
|
||||
try {
|
||||
if (await fileExists(filePath)) return MapEntry(id, true);
|
||||
|
||||
if (entry['storage_mode'] == 'saf') {
|
||||
final treeUri = (entry['download_tree_uri'] as String? ?? '')
|
||||
.trim();
|
||||
var fileName = (entry['saf_file_name'] as String? ?? '').trim();
|
||||
if (fileName.isEmpty && isContentUri(filePath)) {
|
||||
fileName = _fileNameFromUri(filePath);
|
||||
}
|
||||
if (treeUri.isEmpty || fileName.isEmpty) {
|
||||
return MapEntry(id, null);
|
||||
}
|
||||
|
||||
bool treeAccessible;
|
||||
try {
|
||||
treeAccessible = await PlatformBridge.validateSafTreeAccess(
|
||||
treeUri,
|
||||
);
|
||||
} catch (error) {
|
||||
_historyLog.w(
|
||||
'Unable to verify SAF tree while checking $id: $error',
|
||||
);
|
||||
return MapEntry(id, null);
|
||||
}
|
||||
if (!treeAccessible) {
|
||||
return MapEntry(id, null);
|
||||
}
|
||||
|
||||
for (final candidate in _conversionRenameCandidates(
|
||||
fileName,
|
||||
includeAlternateExtensions: true,
|
||||
)) {
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: treeUri,
|
||||
relativeDir: entry['saf_relative_dir'] as String? ?? '',
|
||||
fileName: candidate,
|
||||
);
|
||||
final uri = (resolved['uri'] as String? ?? '').trim();
|
||||
if (uri.isEmpty) continue;
|
||||
replacementPaths[id] = uri;
|
||||
replacementFileNames[id] = candidate;
|
||||
final relativeDir =
|
||||
(resolved['relative_dir'] as String? ?? '').trim();
|
||||
if (relativeDir.isNotEmpty) {
|
||||
replacementRelativeDirs[id] = relativeDir;
|
||||
}
|
||||
pathById[id] = uri;
|
||||
return MapEntry(id, true);
|
||||
} catch (error) {
|
||||
_historyLog.w(
|
||||
'Unable to resolve SAF file while checking $id: $error',
|
||||
);
|
||||
return MapEntry(id, null);
|
||||
}
|
||||
}
|
||||
return MapEntry(id, false);
|
||||
}
|
||||
|
||||
final sibling = await _findConvertedSibling(filePath);
|
||||
if (sibling != null) {
|
||||
_historyLog.i(
|
||||
@@ -798,6 +753,73 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (safEntries.isNotEmpty && Platform.isAndroid) {
|
||||
final requests = <Map<String, dynamic>>[];
|
||||
for (final entry in safEntries) {
|
||||
final id = entry['id'] as String;
|
||||
final filePath = entry['file_path'] as String;
|
||||
var fileName = (entry['saf_file_name'] as String? ?? '').trim();
|
||||
if (fileName.isEmpty && isContentUri(filePath)) {
|
||||
fileName = _fileNameFromUri(filePath);
|
||||
}
|
||||
requests.add({
|
||||
'key': id,
|
||||
'tree_uri': entry['download_tree_uri'] as String? ?? '',
|
||||
'relative_dir': entry['saf_relative_dir'] as String? ?? '',
|
||||
'current_uri': isContentUri(filePath) ? filePath : '',
|
||||
'file_names': _conversionRenameCandidates(
|
||||
fileName,
|
||||
includeAlternateExtensions: true,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final results = await PlatformBridge.inspectSafFiles(requests);
|
||||
final resultsById = <String, Map<String, dynamic>>{
|
||||
for (final result in results)
|
||||
if ((result['key'] as String? ?? '').isNotEmpty)
|
||||
result['key'] as String: result,
|
||||
};
|
||||
for (final entry in safEntries) {
|
||||
final id = entry['id'] as String;
|
||||
final result = resultsById[id];
|
||||
final status = result?['status'] as String? ?? 'unknown';
|
||||
if (status == 'missing') {
|
||||
orphanedIds.add(id);
|
||||
_historyLog.d(
|
||||
'Found orphaned SAF entry: $id (${pathById[id] ?? ''})',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (status != 'found' || result == null) continue;
|
||||
|
||||
final uri = (result['uri'] as String? ?? '').trim();
|
||||
if (uri.isEmpty) continue;
|
||||
final fileName = (result['file_name'] as String? ?? '').trim();
|
||||
final relativeDir = (result['relative_dir'] as String? ?? '').trim();
|
||||
final oldPath = pathById[id] ?? '';
|
||||
final oldFileName = (entry['saf_file_name'] as String? ?? '').trim();
|
||||
final oldRelativeDir = (entry['saf_relative_dir'] as String? ?? '')
|
||||
.trim();
|
||||
if (uri != oldPath ||
|
||||
(fileName.isNotEmpty && fileName != oldFileName) ||
|
||||
(relativeDir.isNotEmpty && relativeDir != oldRelativeDir)) {
|
||||
replacementPaths[id] = uri;
|
||||
if (fileName.isNotEmpty) replacementFileNames[id] = fileName;
|
||||
if (relativeDir.isNotEmpty) {
|
||||
replacementRelativeDirs[id] = relativeDir;
|
||||
}
|
||||
pathById[id] = uri;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// A bridge or provider error is inconclusive. Never delete SAF history
|
||||
// when the batch inspection could not establish that files are absent.
|
||||
_historyLog.w('Unable to inspect SAF history entries: $error');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
orphanedIds: orphanedIds,
|
||||
replacementPaths: replacementPaths,
|
||||
|
||||
@@ -166,27 +166,11 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
|
||||
var verifiedCount = 0;
|
||||
|
||||
try {
|
||||
for (var c = 0; c < selectedIndexes.length; c++) {
|
||||
final i = selectedIndexes[c];
|
||||
final requests = <Map<String, dynamic>>[];
|
||||
for (final i in selectedIndexes) {
|
||||
final item = items[i];
|
||||
final rawPath = item.filePath.trim();
|
||||
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
|
||||
|
||||
if (isDirectSafUri) {
|
||||
final exists = await fileExists(rawPath);
|
||||
if (exists) {
|
||||
final verified = item.copyWith(
|
||||
safRepaired: true,
|
||||
safFileName: item.safFileName ?? _fileNameFromUri(rawPath),
|
||||
);
|
||||
updatedItems[i] = verified;
|
||||
changed = true;
|
||||
verifiedCount++;
|
||||
persistedUpdates.add(verified.toJson());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackName = (item.safFileName ?? '').trim();
|
||||
if (fallbackName.isEmpty && isDirectSafUri) {
|
||||
fallbackName = _fileNameFromUri(rawPath);
|
||||
@@ -195,48 +179,53 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
|
||||
_historyLog.w('Missing SAF filename for history item: ${item.id}');
|
||||
continue;
|
||||
}
|
||||
requests.add({
|
||||
'key': item.id,
|
||||
'tree_uri': item.downloadTreeUri,
|
||||
'relative_dir': item.safRelativeDir ?? '',
|
||||
'current_uri': isDirectSafUri ? rawPath : '',
|
||||
'file_names': _conversionRenameCandidates(fallbackName),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, dynamic>? resolved;
|
||||
String? resolvedFileName;
|
||||
for (final candidate in _conversionRenameCandidates(fallbackName)) {
|
||||
final candidateResult = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: candidate,
|
||||
);
|
||||
final candidateUri = (candidateResult['uri'] as String? ?? '')
|
||||
.trim();
|
||||
if (candidateUri.isEmpty) continue;
|
||||
resolved = candidateResult;
|
||||
resolvedFileName = candidate;
|
||||
break;
|
||||
}
|
||||
if (resolved == null || resolvedFileName == null) continue;
|
||||
final newUri = (resolved['uri'] as String).trim();
|
||||
|
||||
final newRelativeDir = resolved['relative_dir'] as String?;
|
||||
final updated = item.copyWith(
|
||||
filePath: newUri,
|
||||
safRelativeDir:
|
||||
(newRelativeDir != null && newRelativeDir.isNotEmpty)
|
||||
? newRelativeDir
|
||||
: item.safRelativeDir,
|
||||
safFileName: resolvedFileName,
|
||||
safRepaired: true,
|
||||
);
|
||||
|
||||
updatedItems[i] = updated;
|
||||
changed = true;
|
||||
final results = await PlatformBridge.inspectSafFiles(requests);
|
||||
final resultsById = <String, Map<String, dynamic>>{
|
||||
for (final result in results)
|
||||
if ((result['key'] as String? ?? '').isNotEmpty)
|
||||
result['key'] as String: result,
|
||||
};
|
||||
final indexById = <String, int>{
|
||||
for (final index in selectedIndexes) items[index].id: index,
|
||||
};
|
||||
for (final result in resultsById.values) {
|
||||
if (result['status'] != 'found') continue;
|
||||
final id = result['key'] as String;
|
||||
final index = indexById[id];
|
||||
if (index == null) continue;
|
||||
final item = items[index];
|
||||
final newUri = (result['uri'] as String? ?? '').trim();
|
||||
if (newUri.isEmpty) continue;
|
||||
final resultFileName = (result['file_name'] as String? ?? '').trim();
|
||||
final resultRelativeDir = (result['relative_dir'] as String? ?? '')
|
||||
.trim();
|
||||
final updated = item.copyWith(
|
||||
filePath: newUri,
|
||||
safRelativeDir: resultRelativeDir.isNotEmpty
|
||||
? resultRelativeDir
|
||||
: item.safRelativeDir,
|
||||
safFileName: resultFileName.isNotEmpty
|
||||
? resultFileName
|
||||
: item.safFileName,
|
||||
safRepaired: true,
|
||||
);
|
||||
updatedItems[index] = updated;
|
||||
changed = true;
|
||||
if (newUri == item.filePath) {
|
||||
verifiedCount++;
|
||||
} else {
|
||||
repairedCount++;
|
||||
persistedUpdates.add(updated.toJson());
|
||||
} catch (e) {
|
||||
_historyLog.w('Failed to repair SAF URI: $e');
|
||||
}
|
||||
|
||||
if ((c + 1) % DownloadHistoryNotifier._safRepairBatchSize == 0) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 16));
|
||||
}
|
||||
persistedUpdates.add(updated.toJson());
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
|
||||
@@ -646,6 +646,23 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Checks and repairs many SAF file references in a single native pass.
|
||||
/// Android groups requests by tree and scans each tree no more than once.
|
||||
static Future<List<Map<String, dynamic>>> inspectSafFiles(
|
||||
List<Map<String, dynamic>> requests,
|
||||
) async {
|
||||
if (requests.isEmpty) return const [];
|
||||
final response = await _invokeMap('inspectSafFiles', {
|
||||
'requests_json': jsonEncode(requests),
|
||||
});
|
||||
final results = response['results'];
|
||||
if (results is! List) return const [];
|
||||
return results
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map((result) => result.map((key, value) => MapEntry('$key', value)))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static Future<String?> copyContentUriToTemp(String uri) async {
|
||||
final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri});
|
||||
return result as String?;
|
||||
|
||||
Reference in New Issue
Block a user