mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-23 11:22:37 +02:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user