From e46bd71fa7db959d7b35262a0a480af715c46b2d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:50:42 +0100 Subject: [PATCH] fix: prevent completed downloads from restarting on app launch Race condition between trackTasks() completion callbacks and the orphan scan caused completed items to be re-queued on every app restart (#616). --- lib/services/download_manager_service.dart | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 37c7b7d3..4f20040c 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -238,6 +238,9 @@ class DownloadManagerService { // Keys currently being paused — prevents holding queue from promoting them final Set _pausingKeys = {}; + // Keys whose completion callback is in-flight — prevents orphan scan from re-queuing them + final Set _completingKeys = {}; + // Prevents concurrent _processQueue calls bool _isProcessingQueue = false; bool _disposed = false; @@ -356,6 +359,12 @@ class DownloadManagerService { final allDownloads = await _database.select(_database.downloadedMedia).get(); for (final item in allDownloads) { if (item.status == DownloadStatus.downloading.index) { + // Skip items whose completion callback is already in-flight (race with trackTasks) + if (_completingKeys.contains(item.globalKey)) { + appLogger.d('Skipping orphan check for ${item.globalKey}: completion in progress'); + continue; + } + // Video already downloaded but post-processing didn't complete if (item.videoFilePath != null) { appLogger.i('Download ${item.globalKey} has video but incomplete post-processing, completing'); @@ -541,6 +550,14 @@ class DownloadManagerService { /// Resolve metadata, video URL, and file path, then enqueue a background download task. Future _prepareAndEnqueueDownload(String globalKey, PlexClient client, DownloadQueueItem queueItem) async { try { + // Guard: don't re-enqueue an item that's already completed or was deleted + final existing = await _database.getDownloadedMedia(globalKey); + if (existing == null || existing.status == DownloadStatus.completed.index) { + appLogger.d('Skipping enqueue for $globalKey: already completed or deleted'); + await _database.removeFromQueue(globalKey); + return; + } + appLogger.i('Preparing download for $globalKey'); await _transitionStatus(globalKey, DownloadStatus.downloading); @@ -849,10 +866,23 @@ class DownloadManagerService { /// Handle a completed video download — store path, download supplementary content, mark done. Future _onDownloadComplete(String globalKey, Task task) async { + // Prevent duplicate concurrent completions (e.g. trackTasks replaying events) + if (_completingKeys.contains(globalKey)) { + appLogger.d('Already processing completion for $globalKey, skipping'); + return; + } + _completingKeys.add(globalKey); try { // Flush any pending debounced progress write before completing _progressDebounceTimers.remove(globalKey)?.cancel(); + // Fresh DB check — bail if already completed (guards against race with orphan scan) + final existingCheck = await _database.getDownloadedMedia(globalKey); + if (existingCheck?.status == DownloadStatus.completed.index) { + appLogger.d('Download already completed for $globalKey, skipping'); + return; + } + final ctx = _pendingDownloadContext.remove(globalKey); // ── Phase 1 (critical): resolve and store the video file path ── @@ -949,6 +979,7 @@ class DownloadManagerService { await _database.updateDownloadError(globalKey, 'Post-processing failed: $e'); await _database.removeFromQueue(globalKey); } finally { + _completingKeys.remove(globalKey); // Always advance the queue, even after errors if (_lastClient != null) _processQueue(_lastClient!); }