fix: stabilize library scan IDs, pause queue behavior, and scan race condition

- Generate stable SHA-1 based IDs for SAF-scanned library items to prevent null ID crashes on the Dart side
- Suppress false queue-complete notification when user pauses instead of finishing the queue, and break out of parallel loop immediately when paused with no active downloads
- Use SQLite as the single source of truth for library scan results to fix a race condition where auto-scan could fire before provider state finished loading, dropping unchanged rows
This commit is contained in:
zarzet
2026-03-17 23:54:49 +07:00
parent 66a89d9e8e
commit 5ccd06cc68
3 changed files with 50 additions and 12 deletions
@@ -30,6 +30,7 @@ import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
@@ -111,6 +112,13 @@ class MainActivity: FlutterFragmentActivity() {
}
}
private fun buildStableLibraryId(filePath: String): String {
val digest = MessageDigest.getInstance("SHA-1")
val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
val hex = bytes.joinToString("") { "%02x".format(it) }
return "lib_$hex"
}
data class SafScanProgress(
var totalFiles: Int = 0,
var scannedFiles: Int = 0,
@@ -1263,7 +1271,9 @@ class MainActivity: FlutterFragmentActivity() {
} else {
try {
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
metadataObj.put("filePath", doc.uri.toString())
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", lastModified)
results.put(metadataObj)
} catch (_: Exception) {
@@ -1680,7 +1690,9 @@ class MainActivity: FlutterFragmentActivity() {
} else {
try {
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
metadataObj.put("filePath", doc.uri.toString())
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", safeLastModified)
metadataObj.put("lastModified", safeLastModified)
results.put(metadataObj)
+16 -4
View File
@@ -3248,6 +3248,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
await _processQueueParallel();
final stoppedWhilePaused = state.isPaused;
_stopProgressPolling();
@@ -3273,7 +3274,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i(
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
);
if (_totalQueuedAtStart > 0) {
if (!stoppedWhilePaused && _totalQueuedAtStart > 0) {
await _notificationService.showQueueComplete(
completedCount: _completedInSession,
failedCount: _failedInSession,
@@ -3288,13 +3289,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
_log.i('Queue processing finished');
if (stoppedWhilePaused) {
_log.i('Queue processing paused');
} else {
_log.i('Queue processing finished');
}
state = state.copyWith(isProcessing: false, currentDownload: null);
final hasQueuedItems = state.items.any(
(item) => item.status == DownloadStatus.queued,
);
if (hasQueuedItems) {
if (hasQueuedItems && !state.isPaused) {
_log.i(
'Found queued items after processing finished, restarting queue...',
);
@@ -3310,8 +3315,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
while (true) {
if (state.isPaused) {
if (activeDownloads.isEmpty) {
_log.d('Queue is paused and no active downloads remain');
break;
}
_log.d('Queue is paused, waiting for active downloads...');
await Future.delayed(_queueSchedulingInterval);
await Future.any([
Future.wait(activeDownloads.values),
Future.delayed(_queueSchedulingInterval),
]);
continue;
}
+20 -6
View File
@@ -329,6 +329,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (items.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
}
final persistedItems =
(await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now();
try {
@@ -341,7 +346,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
state = state.copyWith(
items: items,
items: persistedItems,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
@@ -350,11 +355,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
_log.i(
'Full scan complete: ${items.length} tracks found, '
'Full scan complete: ${persistedItems.length} tracks found, '
'$skippedDownloads already in downloads',
);
await _showScanCompleteNotification(
totalTracks: items.length,
totalTracks: persistedItems.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
@@ -439,8 +444,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
);
// Build the incremental merge base from SQLite, not the current
// provider state. Startup auto-scan can fire before `state.items` has
// finished loading, which would otherwise drop unchanged rows from the
// in-memory library until a manual full rescan.
final existingJson = await _db.getAll();
final currentByPath = <String, LocalLibraryItem>{
for (final item in state.items) item.filePath: item,
for (final item in existingJson.map(LocalLibraryItem.fromJson))
item.filePath: item,
};
final existingDownloadedPaths = <String>[];
currentByPath.removeWhere((path, _) {
@@ -491,8 +502,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Deleted $deleteCount items from database');
}
final items = currentByPath.values.toList(growable: false)
..sort(_compareLibraryItems);
final items =
(await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now();
try {