From 0ad17f0caa926f9fa61647de7bcf63c19ab7d5e5 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:44:53 +0700 Subject: [PATCH] fix(library): reset checkpoints and artwork on forced full scans --- lib/providers/local_library_provider.dart | 4 + lib/services/platform_bridge.dart | 20 ++++ test/library_full_scan_test.dart | 134 ++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 test/library_full_scan_test.dart diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 5b2ccb5b..1af11c99 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -522,15 +522,18 @@ class LocalLibraryNotifier extends Notifier { required String sourceId, required String folderPath, required bool isSaf, + required bool forceFullScan, }) async { if (_scanCancelRequested) return null; final scanFile = isSaf ? await PlatformBridge.scanSafTreeToNDJSONFile( folderPath, + forceFullScan: forceFullScan, isCancelled: () => _scanCancelRequested, ) : await PlatformBridge.scanLibraryFolderToNDJSONFile( folderPath, + forceFullScan: forceFullScan, isCancelled: () => _scanCancelRequested, ); var ingested = false; @@ -677,6 +680,7 @@ class LocalLibraryNotifier extends Notifier { sourceId: activeSourceId, folderPath: effectiveFolderPath, isSaf: isSaf, + forceFullScan: forceFullScan, ); if (scanResult == null || _scanCancelRequested) { state = state.copyWith( diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 0c48cf9b..5087004c 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -2193,11 +2193,13 @@ class PlatformBridge { static Future scanLibraryFolderToNDJSONFile( String folderPath, { + bool forceFullScan = false, bool Function()? isCancelled, }) { return _scanToNDJSONFile( method: 'scanLibraryFolderToNDJSONFile', arguments: {'folder_path': folderPath}, + forceFullScan: forceFullScan, isCancelled: isCancelled, ); } @@ -2239,11 +2241,13 @@ class PlatformBridge { static Future scanSafTreeToNDJSONFile( String treeUri, { + bool forceFullScan = false, bool Function()? isCancelled, }) { return _scanToNDJSONFile( method: 'scanSafTreeToNDJSONFile', arguments: {'tree_uri': treeUri}, + forceFullScan: forceFullScan, isCancelled: isCancelled, ); } @@ -2251,6 +2255,7 @@ class PlatformBridge { static Future _scanToNDJSONFile({ required String method, required Map arguments, + required bool forceFullScan, bool Function()? isCancelled, }) async { // Stable support-directory path lets native SAF scans resume after process death. @@ -2270,6 +2275,21 @@ class PlatformBridge { 'library_scan_${hash.toRadixString(16).padLeft(8, '0')}.ndjson', ); try { + if (isCancelled?.call() == true) { + throw StateError('Library scan cancelled before native scan'); + } + if (forceFullScan) { + // An explicit rescan must not resume metadata from an earlier attempt. + for (final file in [output, File('${output.path}.state')]) { + if (await file.exists()) await file.delete(); + } + // Preserve covers referenced by the current DB until ingestion succeeds. + // Fresh paths bypass native disk and Flutter image caches together. + final covers = Directory('${scanDir.path}/library_covers'); + await covers.create(recursive: true); + final freshCovers = await covers.createTemp('full_scan_'); + await setLibraryCoverCacheDir(freshCovers.path); + } if (isCancelled?.call() == true) { throw StateError('Library scan cancelled before native scan'); } diff --git a/test/library_full_scan_test.dart b/test/library_full_scan_test.dart new file mode 100644 index 00000000..245d1179 --- /dev/null +++ b/test/library_full_scan_test.dart @@ -0,0 +1,134 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const backend = MethodChannel('com.zarz.spotiflac/backend'); + const paths = MethodChannel('plugins.flutter.io/path_provider'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('library-full-scan-'); + messenger.setMockMethodCallHandler(paths, (_) async => root.path); + }); + tearDown(() async { + messenger.setMockMethodCallHandler(backend, null); + messenger.setMockMethodCallHandler(paths, null); + await root.delete(recursive: true); + }); + + for (final saf in [false, true]) { + Future scan({ + bool force = false, + bool Function()? isCancelled, + }) => saf + ? PlatformBridge.scanSafTreeToNDJSONFile( + 'content://library/tree/music', + forceFullScan: force, + isCancelled: isCancelled, + ) + : PlatformBridge.scanLibraryFolderToNDJSONFile( + '/music', + forceFullScan: force, + isCancelled: isCancelled, + ); + + test('forced scan resets checkpoints and cover paths (SAF=$saf)', () async { + final oldCovers = await Directory('${root.path}/library_covers').create(); + final oldCover = await File( + '${oldCovers.path}/cover_same-key.jpg', + ).writeAsBytes([1]); + final unrelated = await File( + '${root.path}/library_scan_other.ndjson.state', + ).writeAsString('other source'); + final coverDirectories = []; + final resumed = []; + messenger.setMockMethodCallHandler(backend, (call) async { + final args = call.arguments as Map; + if (call.method == 'setLibraryCoverCacheDir') { + final directory = args['cache_dir'] as String; + expect(await Directory(directory).exists(), isTrue); + expect(await File('$directory/cover_same-key.jpg').exists(), isFalse); + coverDirectories.add(directory); + return null; + } + expect( + call.method, + saf ? 'scanSafTreeToNDJSONFile' : 'scanLibraryFolderToNDJSONFile', + ); + final output = File(args['output_path'] as String); + final checkpoint = File('${output.path}.state'); + final hasCheckpoint = await checkpoint.exists(); + expect(await output.exists(), hasCheckpoint); + resumed.add(hasCheckpoint); + await output.writeAsString('{"id":"track"}\n'); + await checkpoint.writeAsString('track\t123\n'); + return {'path': output.path, 'count': 1}; + }); + + final first = await scan(); + final resumedScan = await scan(); + expect(resumedScan.file.path, first.file.path); + expect(coverDirectories, isEmpty); + final forced = await scan(force: true); + expect(await forced.rows().toList(), [ + {'id': 'track'}, + ]); + await scan(force: true); + expect(resumed, [false, true, false, false]); + expect(coverDirectories.toSet(), hasLength(2)); + expect(await oldCover.readAsBytes(), [1]); + expect(await unrelated.readAsString(), 'other source'); + + await forced.delete(); + expect(await forced.file.exists(), isFalse); + expect(await File('${forced.file.path}.state').exists(), isFalse); + }); + + test( + 'cancel before forced scan preserves resumable output (SAF=$saf)', + () async { + var calls = 0; + messenger.setMockMethodCallHandler(backend, (call) async { + calls++; + 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}; + }); + final previous = await scan(); + await expectLater( + scan(force: true, isCancelled: () => true), + throwsStateError, + ); + expect(calls, 1); + expect(await previous.file.readAsString(), '{"id":"old"}\n'); + expect( + await File('${previous.file.path}.state').readAsString(), + 'old\t123\n', + ); + }, + ); + } + + test('cache setup failure stops forced scan before native reads', () async { + final calls = []; + messenger.setMockMethodCallHandler(backend, (call) async { + calls.add(call.method); + throw PlatformException(code: 'cache-unavailable'); + }); + await expectLater( + PlatformBridge.scanSafTreeToNDJSONFile( + 'content://library/tree/music', + forceFullScan: true, + ), + throwsA(isA()), + ); + expect(calls, ['setLibraryCoverCacheDir']); + }); +}