fix(library): preserve partial scans and ignore stale completion events

This commit is contained in:
zarzet committed 2026-09-30 20:57:48 +07:00
1 parent 8aa8d598d8
commit 6c22a91498
6 files changed
+135 -39

No files matched your search

+20 -13
View File
@@ -543,6 +543,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanIsFinalizing: true,
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
scanCurrentFile: null,
scannedFiles: scanFile.expectedCount,
scanErrorCount: scanFile.errorCount,
);
Stream<Map<String, dynamic>> validatedRows() async* {
var decodedRows = 0;
@@ -561,7 +563,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
}
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<LocalLibraryState> {
}
Future<void> _handleLibraryScanProgress(Map<String, dynamic> 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<LocalLibraryState> {
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<LocalLibraryState> {
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<LocalLibraryState> {
if (_shouldShowScanProgressNotification(
progress: normalizedProgress,
totalFiles: totalFiles,
isComplete: isComplete,
isComplete: false,
)) {
await _showScanProgressNotification(
progress: normalizedProgress,
@@ -1016,10 +1027,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
currentFile: currentFile,
);
}
if (isComplete) {
_stopProgressPolling();
}
}
void _stopProgressPolling() {
+20
View File
@@ -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<void> 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<void> deleteLibraryItemsByIds(Database db, Iterable<String> ids) async {
final uniqueIds = ids.toSet().toList();
+6 -9
View File
@@ -1059,6 +1059,7 @@ class LibraryDatabase {
String sourceId,
Stream<Map<String, dynamic>> 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
+7 -6
View File
@@ -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<Map<String, dynamic>> 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;
+5 -11
View File
@@ -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<FormatException>().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));
},
);
}
+77
View File
@@ -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 = <Map<String, Object?>>[];
@override
Future<int> rawDelete(String sql, [List<Object?>? 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<String, dynamic>;
final expected = partial
? (empty
? ['old-id', 'other', 'readable', 'unreadable']
: ['other', 'unreadable'])
: ['other'];
expect(data['rows'], expected);
expect(data['keys'], expected);
},
);
}
}
}