fix(library): preserve indexed tracks when a full scan is incomplete

This commit is contained in:
zarzet
2026-09-28 12:13:17 +07:00
parent 4c2c356763
commit e3bec31b36
6 changed files with 86 additions and 11 deletions
@@ -1907,19 +1907,20 @@ class MainActivity: FlutterFragmentActivity() {
"scanLibraryFolderToNDJSONFile" -> {
val folderPath = call.argument<String>("folder_path") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
val count = withContext(Dispatchers.IO) {
val response = withContext(Dispatchers.IO) {
safScanActive = false
coreBackend.scanLibraryFolderToNdjsonFile(
val count = coreBackend.scanLibraryFolderToNdjsonFile(
folderPath,
outputPath,
)
}
result.success(
mapOf(
"path" to outputPath,
"count" to count,
"error_count" to JSONObject(coreBackend.getLibraryScanProgress())
.getInt("error_count"),
)
)
}
result.success(response)
}
"scanLibraryFolderIncremental" -> {
val folderPath = call.argument<String>("folder_path") ?: ""
@@ -734,7 +734,7 @@ internal fun MainActivity.scanSafTree(
}
File(ndjsonOutputPath).writeText("", Charsets.UTF_8)
try { File(safScanCheckpointPath(ndjsonOutputPath)).delete() } catch (_: Exception) {}
return mapOf("path" to ndjsonOutputPath, "count" to 0)
return mapOf("path" to ndjsonOutputPath, "count" to 0, "error_count" to 0)
}
fun cancelledResult(): Any {
@@ -1152,7 +1152,7 @@ internal fun MainActivity.scanSafTree(
if (ndjsonWriter != null) {
ndjsonWriter.close()
checkpointWriter?.close()
return mapOf("path" to outputPath, "count" to resultCount)
return mapOf("path" to outputPath, "count" to resultCount, "error_count" to errors)
}
spill!!.raw(if (resultCount == 0) "[]" else "]")
return spill.result()
+5 -1
View File
@@ -556,7 +556,11 @@ import UniformTypeIdentifiers
throw invalidArgumentsError(call.method)
}
let count = try coreBackend.scanLibraryFolderToNdjsonFile(folder: folderPath, output: outputPath)
return ["path": outputPath, "count": count]
guard let progress = parseJsonPayload(try coreBackend.getLibraryScanProgress()) as? [String: Any],
let errorCount = progress["error_count"] as? Int else {
throw invalidArgumentsError("scanLibraryFolderToNDJSONFile: missing scan summary")
}
return ["path": outputPath, "count": count, "error_count": errorCount]
case "scanLibraryFolderIncremental":
let args = call.arguments as! [String: Any]
+1 -1
View File
@@ -802,7 +802,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
}
if (_scanCancelRequested) {
if (_scanCancelRequested || result['cancelled'] == true) {
state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
+12
View File
@@ -2328,6 +2328,18 @@ class PlatformBridge {
if (result['cancelled'] == true) {
throw FormatException('$method returned a cancelled partial scan');
}
final errors = result['error_count'];
if (errors is! num ||
!errors.isFinite ||
errors < 0 ||
errors != errors.toInt()) {
throw FormatException('$method returned an invalid error count');
}
if (errors > 0) {
throw FormatException(
'Library scan could not read $errors files; the existing Library was kept',
);
}
final pathValue = result['path'];
final countValue = result['count'];
if (pathValue is! String || pathValue.trim().isEmpty) {
+60 -2
View File
@@ -68,7 +68,7 @@ void main() {
resumed.add(hasCheckpoint);
await output.writeAsString('{"id":"track"}\n');
await checkpoint.writeAsString('track\t123\n');
return {'path': output.path, 'count': 1};
return {'path': output.path, 'count': 1, 'error_count': 0};
});
final first = await scan();
@@ -99,7 +99,7 @@ void main() {
final output = File((call.arguments as Map)['output_path'] as String);
await output.writeAsString('{"id":"old"}\n');
await File('${output.path}.state').writeAsString('old\t123\n');
return {'path': output.path, 'count': 1};
return {'path': output.path, 'count': 1, 'error_count': 0};
});
final previous = await scan();
await expectLater(
@@ -116,6 +116,64 @@ void main() {
);
}
for (final saf in [false, true]) {
Future<LibraryScanNDJSONFile> scan() => saf
? PlatformBridge.scanSafTreeToNDJSONFile('content://library/tree/music')
: PlatformBridge.scanLibraryFolderToNDJSONFile('/music');
for (final count in [0, 2]) {
test(
'partial scan cannot replace the index ($count rows, SAF=$saf)',
() async {
messenger.setMockMethodCallHandler(backend, (call) async {
final output = File(
(call.arguments as Map)['output_path'] as String,
);
await output.writeAsString('{"id":"readable"}\n' * count);
return {'path': output.path, 'count': count, 'error_count': 3};
});
await expectLater(
scan(),
throwsA(
isA<FormatException>().having(
(error) => error.message,
'reason',
contains('existing Library was kept'),
),
),
);
},
);
}
test('a confirmed empty folder can clear its index (SAF=$saf)', () async {
messenger.setMockMethodCallHandler(backend, (call) async {
final output = File((call.arguments as Map)['output_path'] as String);
await output.writeAsString('');
return {'path': output.path, 'count': 0, 'error_count': 0};
});
final empty = await scan();
expect(empty.expectedCount, 0);
expect(await empty.rows().toList(), isEmpty);
});
for (final errors in [null, -1, 0.5, '0']) {
test(
'missing or invalid error summary is rejected ($errors, SAF=$saf)',
() async {
messenger.setMockMethodCallHandler(backend, (call) async {
final output = File(
(call.arguments as Map)['output_path'] as String,
);
await output.writeAsString('');
return {'path': output.path, 'count': 0, 'error_count': errors};
});
await expectLater(scan(), throwsFormatException);
},
);
}
}
test('cache setup failure stops forced scan before native reads', () async {
final calls = <String>[];
messenger.setMockMethodCallHandler(backend, (call) async {