diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 4c66de83..7ac64300 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -543,6 +543,8 @@ class LocalLibraryNotifier extends Notifier { scanIsFinalizing: true, scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99, scanCurrentFile: null, + scannedFiles: scanFile.expectedCount, + scanErrorCount: scanFile.errorCount, ); Stream> validatedRows() async* { var decodedRows = 0; @@ -561,7 +563,11 @@ class LocalLibraryNotifier extends Notifier { } } - final result = await _db.replaceSourceStream(sourceId, validatedRows()); + final result = await _db.replaceSourceStream( + sourceId, + validatedRows(), + preserveMissing: scanFile.errorCount > 0, + ); _log.i( 'Stream-ingested ${result.inserted}/${scanFile.expectedCount} scan rows ' '(${result.skipped} downloads excluded)', @@ -970,16 +976,23 @@ class LocalLibraryNotifier extends Notifier { } Future _handleLibraryScanProgress(Map progress) async { - if (_scanCancelRequested) return; + // A newly subscribed native stream may still contain the previous scan's + // terminal snapshot (including the initial idle snapshot). Only the scan + // method's result can start finalization; never stop polling on that replay. + if (_scanCancelRequested || + !state.isScanning || + state.scanIsFinalizing || + progress['is_complete'] == true) { + return; + } final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( 0.0, 100.0, ); - final isComplete = progress['is_complete'] == true; - final displayProgress = isComplete + final displayProgress = normalizedProgress >= 100.0 ? 99.0 - : (normalizedProgress >= 100.0 ? 99.0 : normalizedProgress); + : normalizedProgress; final currentFile = progress['current_file'] as String?; final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0; final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0; @@ -987,7 +1000,6 @@ class LocalLibraryNotifier extends Notifier { final shouldUpdateState = state.scanProgress != displayProgress || - state.scanIsFinalizing != isComplete || state.scanCurrentFile != currentFile || state.scanTotalFiles != totalFiles || state.scannedFiles != scannedFiles || @@ -995,9 +1007,8 @@ class LocalLibraryNotifier extends Notifier { if (shouldUpdateState) { state = state.copyWith( - scanIsFinalizing: isComplete, scanProgress: displayProgress, - scanCurrentFile: isComplete ? null : currentFile, + scanCurrentFile: currentFile, scanTotalFiles: totalFiles, scannedFiles: scannedFiles, scanErrorCount: errorCount, @@ -1007,7 +1018,7 @@ class LocalLibraryNotifier extends Notifier { if (_shouldShowScanProgressNotification( progress: normalizedProgress, totalFiles: totalFiles, - isComplete: isComplete, + isComplete: false, )) { await _showScanProgressNotification( progress: normalizedProgress, @@ -1016,10 +1027,6 @@ class LocalLibraryNotifier extends Notifier { currentFile: currentFile, ); } - - if (isComplete) { - _stopProgressPolling(); - } } void _stopProgressPolling() { diff --git a/lib/services/library_cleanup.dart b/lib/services/library_cleanup.dart index 65c1be4f..d4a262d6 100644 --- a/lib/services/library_cleanup.dart +++ b/lib/services/library_cleanup.dart @@ -3,6 +3,26 @@ import 'package:sqflite/sqflite.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; +/// Removes rows superseded by a staged scan inside the caller's transaction. +/// On partial scans, an unreadable file is not evidence that it was deleted. +Future deleteReplacedLibraryScanRows( + DatabaseExecutor db, + String sourceId, { + required String stageTable, + required bool preserveMissing, +}) async { + final where = preserveMissing + ? 'source_id = ? AND (id IN (SELECT id FROM $stageTable) ' + 'OR file_path IN (SELECT file_path FROM $stageTable))' + : 'source_id = ?'; + await db.rawDelete( + 'DELETE FROM library_path_keys WHERE item_id IN ' + '(SELECT id FROM library WHERE $where)', + [sourceId], + ); + await db.rawDelete('DELETE FROM library WHERE $where', [sourceId]); +} + /// Deletes a selection atomically without exceeding SQLite's parameter budget. Future deleteLibraryItemsByIds(Database db, Iterable ids) async { final uniqueIds = ids.toSet().toList(); diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 2c51557f..cbd0ae94 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -1059,6 +1059,7 @@ class LibraryDatabase { String sourceId, Stream> items, { int batchSize = 300, + bool preserveMissing = false, }) async { if (batchSize <= 0) { throw ArgumentError.value(batchSize, 'batchSize', 'Must be positive'); @@ -1148,15 +1149,11 @@ class LibraryDatabase { final selectedColumns = columns.map((column) => 's.$column').join(', '); await db.transaction((txn) async { - await txn.rawDelete( - 'DELETE FROM library_path_keys WHERE item_id IN ' - '(SELECT id FROM library WHERE source_id = ?)', - [sourceId], - ); - await txn.delete( - 'library', - where: 'source_id = ?', - whereArgs: [sourceId], + await deleteReplacedLibraryScanRows( + txn, + sourceId, + stageTable: _scanStageTable, + preserveMissing: preserveMissing, ); await txn.rawDelete(''' DELETE FROM library_path_keys diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 12d53095..50a86d96 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -30,10 +30,12 @@ Object? _decodeJsonFileInBackground(String path) { class LibraryScanNDJSONFile { final File file; final int expectedCount; + final int errorCount; const LibraryScanNDJSONFile({ required this.file, required this.expectedCount, + this.errorCount = 0, }); Stream> rows() async* { @@ -2344,11 +2346,6 @@ class PlatformBridge { 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) { @@ -2366,7 +2363,11 @@ class PlatformBridge { if (!await file.exists()) { throw FormatException('$method did not create its output file'); } - return LibraryScanNDJSONFile(file: file, expectedCount: count); + return LibraryScanNDJSONFile( + file: file, + expectedCount: count, + errorCount: errors.toInt(), + ); } catch (_) { // Keep partial output; native SAF scan resumes from its sidecar. rethrow; diff --git a/test/library_full_scan_test.dart b/test/library_full_scan_test.dart index f970ba8c..7a92ee2f 100644 --- a/test/library_full_scan_test.dart +++ b/test/library_full_scan_test.dart @@ -123,7 +123,7 @@ void main() { for (final count in [0, 2]) { test( - 'partial scan cannot replace the index ($count rows, SAF=$saf)', + 'partial scan exposes readable rows and errors ($count rows, SAF=$saf)', () async { messenger.setMockMethodCallHandler(backend, (call) async { final output = File( @@ -132,16 +132,10 @@ void main() { 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'), - ), - ), - ); + final partial = await scan(); + expect(partial.errorCount, 3); + expect(partial.expectedCount, count); + expect(await partial.rows().toList(), hasLength(count)); }, ); } diff --git a/test/library_partial_scan_test.dart b/test/library_partial_scan_test.dart new file mode 100644 index 00000000..f9fcefc9 --- /dev/null +++ b/test/library_partial_scan_test.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/library_cleanup.dart'; +import 'package:sqflite/sqflite.dart'; + +class _Statements implements DatabaseExecutor { + final statements = >[]; + + @override + Future rawDelete(String sql, [List? arguments]) async { + statements.add({'sql': sql, 'args': arguments}); + return 0; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + for (final partial in [false, true]) { + for (final empty in [false, true]) { + test( + 'scan replacement preserves unreadable rows only on partial scans ($partial, empty=$empty)', + () async { + final db = _Statements(); + await deleteReplacedLibraryScanRows( + db, + 'sd-card', + stageTable: 'staged', + preserveMissing: partial, + ); + // Run the actual generated SQL in SQLite, including path-key cleanup. + final result = await Process.run('python3', [ + '-c', + r''' +import json, sqlite3, sys +args = json.loads(sys.argv[1]) +db = sqlite3.connect(':memory:') +db.executescript(""" +CREATE TABLE library(id TEXT PRIMARY KEY, file_path TEXT UNIQUE, source_id TEXT); +CREATE TABLE library_path_keys(item_id TEXT, path_key TEXT); +CREATE TABLE staged(id TEXT, file_path TEXT); +INSERT INTO library VALUES ('readable', '/sd/readable.flac', 'sd-card'), + ('old-id', '/sd/changed.flac', 'sd-card'), + ('unreadable', '/sd/unreadable.flac', 'sd-card'), + ('other', '/internal/track.flac', 'internal'); +INSERT INTO library_path_keys SELECT id, file_path FROM library; +""") +if not args['empty']: + db.executescript("INSERT INTO staged VALUES ('readable', '/sd/readable.flac'), ('new-id', '/sd/changed.flac');") +with db: + for statement in args['statements']: + db.execute(statement['sql'], statement['args']) +print(json.dumps({ + 'rows': [row[0] for row in db.execute('SELECT id FROM library ORDER BY id')], + 'keys': [row[0] for row in db.execute('SELECT item_id FROM library_path_keys ORDER BY item_id')] +})) +''', + jsonEncode({'statements': db.statements, 'empty': empty}), + ]); + expect(result.exitCode, 0, reason: result.stderr.toString()); + final data = + jsonDecode(result.stdout as String) as Map; + final expected = partial + ? (empty + ? ['old-id', 'other', 'readable', 'unreadable'] + : ['other', 'unreadable']) + : ['other']; + expect(data['rows'], expected); + expect(data['keys'], expected); + }, + ); + } + } +}