From e3bec31b36e32367ec7eb505852faac39b731d45 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:13:17 +0700 Subject: [PATCH] fix(library): preserve indexed tracks when a full scan is incomplete --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 11 ++-- .../com/zarz/spotiflac/MainActivitySafScan.kt | 4 +- ios/Runner/AppDelegate.swift | 6 +- lib/providers/local_library_provider.dart | 2 +- lib/services/platform_bridge.dart | 12 ++++ test/library_full_scan_test.dart | 62 ++++++++++++++++++- 6 files changed, 86 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c1a07a67..e28990aa 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1907,19 +1907,20 @@ class MainActivity: FlutterFragmentActivity() { "scanLibraryFolderToNDJSONFile" -> { val folderPath = call.argument("folder_path") ?: "" val outputPath = call.argument("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("folder_path") ?: "" diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index aec273ff..0b3565e4 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -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() diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 249cc928..5ff66f72 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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] diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 5e266b2e..4c66de83 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -802,7 +802,7 @@ class LocalLibraryNotifier extends Notifier { } } - if (_scanCancelRequested) { + if (_scanCancelRequested || result['cancelled'] == true) { state = state.copyWith( isScanning: false, scanIsFinalizing: false, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 0791722b..433b4e74 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -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) { diff --git a/test/library_full_scan_test.dart b/test/library_full_scan_test.dart index 245d1179..f970ba8c 100644 --- a/test/library_full_scan_test.dart +++ b/test/library_full_scan_test.dart @@ -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 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().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 = []; messenger.setMockMethodCallHandler(backend, (call) async {