From 5ccd06cc680eca3e8a287dd4b11e7a664fc76d01 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 17 Mar 2026 23:54:49 +0700 Subject: [PATCH] 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 --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 16 ++++++++++-- lib/providers/download_queue_provider.dart | 20 +++++++++++--- lib/providers/local_library_provider.dart | 26 ++++++++++++++----- 3 files changed, 50 insertions(+), 12 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 5ffe598..ada75b9 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 5898445..bbac0b1 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3248,6 +3248,7 @@ class DownloadQueueNotifier extends Notifier { } _log.d('Concurrent downloads: ${state.concurrentDownloads}'); await _processQueueParallel(); + final stoppedWhilePaused = state.isPaused; _stopProgressPolling(); @@ -3273,7 +3274,7 @@ class DownloadQueueNotifier extends Notifier { _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 { } } - _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 { 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; } diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 02fa69b..ab5416c 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -329,6 +329,11 @@ class LocalLibraryNotifier extends Notifier { 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 { } state = state.copyWith( - items: items, + items: persistedItems, isScanning: false, scanProgress: 100, lastScannedAt: now, @@ -350,11 +355,11 @@ class LocalLibraryNotifier extends Notifier { ); _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 { '$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 = { - for (final item in state.items) item.filePath: item, + for (final item in existingJson.map(LocalLibraryItem.fromJson)) + item.filePath: item, }; final existingDownloadedPaths = []; currentByPath.removeWhere((path, _) { @@ -491,8 +502,11 @@ class LocalLibraryNotifier extends Notifier { _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 {