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.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Locale import java.util.Locale
class MainActivity: FlutterFragmentActivity() { 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( data class SafScanProgress(
var totalFiles: Int = 0, var totalFiles: Int = 0,
var scannedFiles: Int = 0, var scannedFiles: Int = 0,
@@ -1263,7 +1271,9 @@ class MainActivity: FlutterFragmentActivity() {
} else { } else {
try { try {
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } 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) metadataObj.put("fileModTime", lastModified)
results.put(metadataObj) results.put(metadataObj)
} catch (_: Exception) { } catch (_: Exception) {
@@ -1680,7 +1690,9 @@ class MainActivity: FlutterFragmentActivity() {
} else { } else {
try { try {
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } 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("fileModTime", safeLastModified)
metadataObj.put("lastModified", safeLastModified) metadataObj.put("lastModified", safeLastModified)
results.put(metadataObj) results.put(metadataObj)
+16 -4
View File
@@ -3248,6 +3248,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
_log.d('Concurrent downloads: ${state.concurrentDownloads}'); _log.d('Concurrent downloads: ${state.concurrentDownloads}');
await _processQueueParallel(); await _processQueueParallel();
final stoppedWhilePaused = state.isPaused;
_stopProgressPolling(); _stopProgressPolling();
@@ -3273,7 +3274,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i( _log.i(
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart', 'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
); );
if (_totalQueuedAtStart > 0) { if (!stoppedWhilePaused && _totalQueuedAtStart > 0) {
await _notificationService.showQueueComplete( await _notificationService.showQueueComplete(
completedCount: _completedInSession, completedCount: _completedInSession,
failedCount: _failedInSession, 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); state = state.copyWith(isProcessing: false, currentDownload: null);
final hasQueuedItems = state.items.any( final hasQueuedItems = state.items.any(
(item) => item.status == DownloadStatus.queued, (item) => item.status == DownloadStatus.queued,
); );
if (hasQueuedItems) { if (hasQueuedItems && !state.isPaused) {
_log.i( _log.i(
'Found queued items after processing finished, restarting queue...', 'Found queued items after processing finished, restarting queue...',
); );
@@ -3310,8 +3315,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
while (true) { while (true) {
if (state.isPaused) { 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...'); _log.d('Queue is paused, waiting for active downloads...');
await Future.delayed(_queueSchedulingInterval); await Future.any([
Future.wait(activeDownloads.values),
Future.delayed(_queueSchedulingInterval),
]);
continue; continue;
} }
+20 -6
View File
@@ -329,6 +329,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (items.isNotEmpty) { if (items.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList()); 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(); final now = DateTime.now();
try { try {
@@ -341,7 +346,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} }
state = state.copyWith( state = state.copyWith(
items: items, items: persistedItems,
isScanning: false, isScanning: false,
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
@@ -350,11 +355,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
_log.i( _log.i(
'Full scan complete: ${items.length} tracks found, ' 'Full scan complete: ${persistedItems.length} tracks found, '
'$skippedDownloads already in downloads', '$skippedDownloads already in downloads',
); );
await _showScanCompleteNotification( await _showScanCompleteNotification(
totalTracks: items.length, totalTracks: persistedItems.length,
excludedDownloadedCount: skippedDownloads, excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount, errorCount: state.scanErrorCount,
); );
@@ -439,8 +444,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', '$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>{ 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>[]; final existingDownloadedPaths = <String>[];
currentByPath.removeWhere((path, _) { currentByPath.removeWhere((path, _) {
@@ -491,8 +502,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Deleted $deleteCount items from database'); _log.i('Deleted $deleteCount items from database');
} }
final items = currentByPath.values.toList(growable: false) final items =
..sort(_compareLibraryItems); (await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now(); final now = DateTime.now();
try { try {