diff --git a/web_server.py b/web_server.py index d8c0cfdf..b5341cdc 100644 --- a/web_server.py +++ b/web_server.py @@ -232,8 +232,7 @@ class WebUIDownloadMonitor: live_transfers_lookup = self._get_live_transfers() with tasks_lock: - tasks_to_retry = [] - + # Check all monitored batches for timeouts and errors for batch_id in list(self.monitored_batches): if batch_id not in download_batches: self.monitored_batches.discard(batch_id) @@ -244,14 +243,8 @@ class WebUIDownloadMonitor: if not task or task['status'] not in ['downloading', 'queued']: continue - # Check if task needs retry due to timeout - if self._should_retry_task(task, live_transfers_lookup, current_time): - tasks_to_retry.append((batch_id, task_id, task)) - - # Process retries outside the lock to avoid deadlocks - for batch_id, task_id, task in tasks_to_retry: - print(f"πŸ”„ Monitor triggered retry for task {task_id}") - self._trigger_retry(batch_id, task_id, task) + # Check for timeouts and errors - retries handled directly in _should_retry_task + self._should_retry_task(task, live_transfers_lookup, current_time) # ENHANCED: Add worker count validation to detect ghost workers self._validate_worker_counts() @@ -309,8 +302,29 @@ class WebUIDownloadMonitor: state_str = live_info.get('state', '') progress = live_info.get('percentComplete', 0) + # IMMEDIATE ERROR RETRY: Check for errored downloads first (no timeout needed) + if 'Errored' in state_str or 'Failed' in state_str: + retry_count = task.get('error_retry_count', 0) + last_retry = task.get('last_error_retry_time', 0) + + # Don't retry too frequently (wait at least 5 seconds between error retries) + if retry_count < 3 and (current_time - last_retry) > 5: # Max 3 error retry attempts + print(f"🚨 Task errored (state: {state_str}) - immediate retry {retry_count + 1}/3") + task['error_retry_count'] = retry_count + 1 + task['last_error_retry_time'] = current_time + return True + elif retry_count < 3: + # Wait a bit before next error retry + return False + else: + # Too many error retries, mark as failed + print(f"❌ Task failed after 3 error retry attempts") + task['status'] = 'failed' + task['error_message'] = 'Failed after multiple error retries' + return False + # Check for queued timeout (90 seconds like GUI) - if 'Queued' in state_str or task['status'] == 'queued': + elif 'Queued' in state_str or task['status'] == 'queued': if 'queued_start_time' not in task: task['queued_start_time'] = current_time return False @@ -330,10 +344,28 @@ class WebUIDownloadMonitor: # Don't retry too frequently (wait at least 30 seconds between retries) if retry_count < 3 and (current_time - last_retry) > 30: # Max 3 retry attempts - print(f"⚠️ Task stuck in queue for {queue_time:.1f}s (retry {retry_count + 1}/3)") + print(f"⚠️ Task stuck in queue for {queue_time:.1f}s - immediate retry {retry_count + 1}/3") task['stuck_retry_count'] = retry_count + 1 task['last_retry_time'] = current_time - return True + + # UNIFIED RETRY LOGIC: Handle timeout retry exactly like error retry + # Mark current source as used to prevent retry loops + username = task.get('username') or task['track_info'].get('username') + filename = task.get('filename') or task['track_info'].get('filename') + if username and filename: + used_sources = task.get('used_sources', set()) + source_key = f"{username}_{os.path.basename(filename)}" + used_sources.add(source_key) + task['used_sources'] = used_sources + print(f"🚫 Marked timeout source as used: {source_key}") + + # Reset task state for immediate retry (like error retry) + task['status'] = 'searching' + task.pop('queued_start_time', None) + task.pop('downloading_start_time', None) + task['status_change_time'] = current_time + print(f"πŸ”„ Task {task.get('track_info', {}).get('name', 'Unknown')} reset for timeout retry") + return False # Don't trigger monitor retry - task will be picked up by normal flow elif retry_count < 3: # Wait longer before next retry return False @@ -367,10 +399,28 @@ class WebUIDownloadMonitor: # Don't retry too frequently (wait at least 30 seconds between retries) if retry_count < 3 and (current_time - last_retry) > 30: # Max 3 retry attempts - print(f"⚠️ Task stuck at 0% for {download_time:.1f}s (retry {retry_count + 1}/3)") + print(f"⚠️ Task stuck at 0% for {download_time:.1f}s - immediate retry {retry_count + 1}/3") task['stuck_retry_count'] = retry_count + 1 task['last_retry_time'] = current_time - return True + + # UNIFIED RETRY LOGIC: Handle 0% timeout retry exactly like error retry + # Mark current source as used to prevent retry loops + username = task.get('username') or task['track_info'].get('username') + filename = task.get('filename') or task['track_info'].get('filename') + if username and filename: + used_sources = task.get('used_sources', set()) + source_key = f"{username}_{os.path.basename(filename)}" + used_sources.add(source_key) + task['used_sources'] = used_sources + print(f"🚫 Marked 0% progress source as used: {source_key}") + + # Reset task state for immediate retry (like error retry) + task['status'] = 'searching' + task.pop('queued_start_time', None) + task.pop('downloading_start_time', None) + task['status_change_time'] = current_time + print(f"πŸ”„ Task {task.get('track_info', {}).get('name', 'Unknown')} reset for 0% retry") + return False # Don't trigger monitor retry - task will be picked up by normal flow elif retry_count < 3: # Wait longer before next retry return False @@ -390,170 +440,6 @@ class WebUIDownloadMonitor: return False - def _trigger_retry(self, batch_id, task_id, task): - """Trigger retry for a stuck/failed task""" - try: - # Cancel the stuck download first (like GUI) - self._cancel_download_before_retry(task, task_id) - - # CRITICAL: Mark current source as used to prevent retry loops (like sync.py does) - username = task.get('username') - filename = task.get('filename') - if username and filename: - used_sources = task.get('used_sources', set()) - source_key = f"{username}_{os.path.basename(filename)}" - used_sources.add(source_key) - task['used_sources'] = used_sources - print(f"🚫 Marked failed source as used: {source_key}") - - # NOTE: Don't call _on_download_completed here - let the retry handle it - # The error system doesn't call _on_download_completed during retry either - - # Update task for retry with timeout to prevent deadlock - lock_acquired = tasks_lock.acquire(timeout=2.0) # 2-second timeout - if not lock_acquired: - print(f"⚠️ Could not acquire lock for retry {task_id}, will try again next cycle") - return - - try: - if task_id in download_tasks: - task['retry_count'] = task.get('retry_count', 0) + 1 - if task['retry_count'] > 2: # Max 3 attempts total like GUI - task['status'] = 'failed' - print(f"❌ Task {task_id} failed after 3 retry attempts") - return - - # Reset for retry - task['status'] = 'pending' - task.pop('queued_start_time', None) - task.pop('downloading_start_time', None) - task['status_change_time'] = time.time() - finally: - tasks_lock.release() - # Submit retry to executor - missing_download_executor.submit(self._retry_task_with_fallback, batch_id, task_id, task) - - except Exception as e: - print(f"❌ Error triggering retry for task {task_id}: {e}") - - def _cancel_download_before_retry(self, task, task_id): - """Cancel current download before retry (matches GUI cancel_download_before_retry)""" - try: - download_id = task.get('download_id') - username = task.get('username') or task['track_info'].get('username') - filename = task.get('filename') or task['track_info'].get('filename') - - # Only attempt cancellation if we have what looks like a proper download ID - # (not a filename fallback which would be much longer) - # Handle cancellation attempts based on download ID type - if download_id and username and isinstance(download_id, str): - # Check if this is a synthetic download ID (generated by web server) - if download_id.startswith('web_dl_'): - print(f"⚠️ Skipping cancellation for synthetic download ID {download_id} - cannot cancel via slskd API") - # Use original download ID for potential cancellation if available - original_id = task.get('original_download_id') - if original_id and len(str(original_id)) > 100: - # This is still a filename, can't cancel - print(f"⚠️ Original download ID is filename, cannot cancel: {str(original_id)[:50]}...") - else: - print(f"⚠️ No usable original download ID for cancellation") - elif '\\' not in str(download_id) and '/' not in str(download_id) and len(download_id) < 200: - # This looks like a proper slskd download ID (no path separators), try to cancel it - print(f"🚫 Attempting to cancel stuck download: {download_id[:8]}... from {username} (task: {task_id[:8]}...)") - try: - success = asyncio.run(soulseek_client.cancel_download(download_id, username, remove=False)) - if success: - print(f"βœ… Successfully cancelled download {download_id[:8]}... from {username}") - # Clear any stored download info to prevent status conflicts - task.pop('soulseek_download_id', None) - task.pop('soulseek_username', None) - else: - print(f"⚠️ Cancel request failed for {download_id[:8]}... - API error, proceeding with retry anyway") - except Exception as e: - print(f"⚠️ Cancel exception for {download_id[:8]}...: {str(e)[:100]}, proceeding with retry anyway") - else: - # Download ID is too long - likely a filename, need to find real download ID via API - print(f"πŸ” Download ID appears to be filename ({len(download_id)} chars), searching for real download ID...") - real_download_id = self._find_download_id_by_filename(username, filename or download_id) - if real_download_id: - print(f"βœ… Found real download ID: {real_download_id[:8]}... for file path") - try: - success = asyncio.run(soulseek_client.cancel_download(real_download_id, username, remove=False)) - if success: - print(f"βœ… Successfully cancelled download {real_download_id[:8]}... from {username}") - task.pop('soulseek_download_id', None) - task.pop('soulseek_username', None) - else: - print(f"⚠️ Cancel request failed for {real_download_id[:8]}... - API error, proceeding with retry anyway") - except Exception as e: - print(f"⚠️ Cancel exception for {real_download_id[:8]}...: {str(e)[:100]}, proceeding with retry anyway") - else: - print(f"⚠️ Could not find real download ID for filename, proceeding with retry anyway") - else: - print(f"⚠️ Missing download ID or username for cancellation, proceeding with retry") - - except Exception as e: - print(f"⚠️ Error in cancellation logic, proceeding with retry: {e}") - - def _find_download_id_by_filename(self, username, filename): - """Find the real download ID by looking up filename in slskd transfers""" - try: - transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads')) - if not transfers_data: - return None - - target_basename = os.path.basename(filename).lower() - - for user_data in transfers_data: - if user_data.get('username') == username: - # Check files in directories - for directory in user_data.get('directories', []): - for file_info in directory.get('files', []): - file_basename = os.path.basename(file_info.get('filename', '')).lower() - if file_basename == target_basename: - return file_info.get('id') - - # Also check files directly under user (some API responses structure this way) - for file_info in user_data.get('files', []): - file_basename = os.path.basename(file_info.get('filename', '')).lower() - if file_basename == target_basename: - return file_info.get('id') - - return None - except Exception as e: - print(f"⚠️ Error finding download ID by filename: {e}") - return None - - def _retry_task_with_fallback(self, batch_id, task_id, task): - """Retry task with next candidate (matches GUI retry logic)""" - try: - candidates = task.get('cached_candidates', []) - used_sources = task.get('used_sources', set()) - - # Find next unused candidate - next_candidate = None - for candidate in candidates: - source_key = f"{candidate.username}_{candidate.filename}" - if source_key not in used_sources: - next_candidate = candidate - break - - if not next_candidate: - with tasks_lock: - if task_id in download_tasks: - download_tasks[task_id]['status'] = 'failed' - print(f"❌ No alternative candidates for task {task_id}") - return - - print(f"πŸ”„ Retrying task {task_id} with candidate: {next_candidate.filename}") - - # Use the existing retry logic from _attempt_download_with_candidates - track = task['track_info'].get('spotify_track') - if track: - _attempt_download_with_candidates(task_id, [next_candidate], track, batch_id) - - except Exception as e: - print(f"❌ Error in retry with fallback for task {task_id}: {e}") def _validate_worker_counts(self): """ @@ -4150,8 +4036,16 @@ def _post_process_matched_download_with_verification(context_key, context, file_ try: print(f"🎯 [Verification] Starting enhanced post-processing for: {context_key}") - # Call the existing post-processing logic + # Call the existing post-processing logic (but skip its completion callback) + # We'll handle the completion callback ourselves after verification + original_task_id = context.pop('task_id', None) # Temporarily remove to prevent double callback + original_batch_id = context.pop('batch_id', None) _post_process_matched_download(context_key, context, file_path) + # Restore the IDs for our own callback + if original_task_id: + context['task_id'] = original_task_id + if original_batch_id: + context['batch_id'] = original_batch_id # CRITICAL VERIFICATION STEP: Verify the final file exists # Extract the expected final path from the context or reconstruct it @@ -4208,9 +4102,9 @@ def _post_process_matched_download_with_verification(context_key, context, file_ if task_id in download_tasks: download_tasks[task_id]['status'] = 'completed' print(f"βœ… [Verification] Task {task_id} marked as completed after verification") - # NOTE: _on_download_completed is already called by the original post-processing - # Do not call it again to avoid double-completion issues - print(f"βœ… [Verification] Task {task_id} verification complete - batch callback handled by original post-processing") + # FIXED: Call completion callback now since we prevented original post-processing from calling it + print(f"βœ… [Verification] Task {task_id} verification complete - calling batch completion callback") + _on_download_completed(batch_id, task_id, success=True) else: print(f"❌ [Verification] File move verification failed - not found at: {expected_final_path}") with tasks_lock: @@ -4441,6 +4335,13 @@ def _post_process_matched_download(context_key, context, file_path): batch_id = context.get('batch_id') if task_id and batch_id: print(f"🎯 [Post-Process] Calling completion callback for task {task_id} in batch {batch_id}") + + # CRITICAL: Mark task as stream processed to prevent verification workflow from running + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['stream_processed'] = True + print(f"βœ… [Post-Process] Marked task {task_id} as stream processed") + _on_download_completed(batch_id, task_id, success=True) except Exception as e: @@ -5337,13 +5238,37 @@ def _on_download_completed(batch_id, task_id, success=True): print(f"πŸ”„ [Batch Manager] Task {task_id} completed ({'success' if success else 'failed/cancelled'}). Active workers: {old_active} β†’ {new_active}/{download_batches[batch_id]['max_concurrent']}") - # Check if batch is fully complete (all downloads processed and no active workers) + # FIXED: Check if batch is truly complete (all tasks finished, not just workers freed) batch = download_batches[batch_id] - all_processed = batch['queue_index'] >= len(batch['queue']) - no_active = batch['active_count'] == 0 + all_tasks_started = batch['queue_index'] >= len(batch['queue']) + no_active_workers = batch['active_count'] == 0 - if all_processed and no_active: - print(f"πŸŽ‰ [Batch Manager] Batch {batch_id} fully complete - processing failed tracks to wishlist") + # Count actually finished tasks (completed, failed, or cancelled) + # CRITICAL: Don't include 'post_processing' as finished - it's still in progress! + # CRITICAL: Don't include 'searching' as finished - task is being retried! + finished_count = 0 + retrying_count = 0 + queue = batch.get('queue', []) + for task_id in queue: + if task_id in download_tasks: + task_status = download_tasks[task_id]['status'] + if task_status in ['completed', 'failed', 'cancelled']: + finished_count += 1 + elif task_status == 'searching': + retrying_count += 1 + + all_tasks_truly_finished = finished_count >= len(queue) + has_retrying_tasks = retrying_count > 0 + + if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks: + print(f"πŸŽ‰ [Batch Manager] Batch {batch_id} truly complete - all {finished_count}/{len(queue)} tasks finished - processing failed tracks to wishlist") + elif all_tasks_started and no_active_workers and has_retrying_tasks: + print(f"πŸ”„ [Batch Manager] Batch {batch_id}: all workers free but {retrying_count} tasks retrying - continuing monitoring") + elif all_tasks_started and no_active_workers: + # This used to incorrectly mark batch as complete! + print(f"πŸ“Š [Batch Manager] Batch {batch_id}: all workers free but only {finished_count}/{len(queue)} tasks finished - continuing monitoring") + + if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks: # Check if this is an auto-initiated batch is_auto_batch = batch.get('auto_initiated', False) @@ -6143,22 +6068,21 @@ def _build_batch_status_data(batch_id, batch, live_transfers_lookup): task_status['status'] = 'cancelled' task['status'] = 'cancelled' elif 'Failed' in state_str or 'Errored' in state_str: - # Don't mark as failed immediately - trigger retry like GUI - batch_id_for_retry = None - for bid, batch_check in download_batches.items(): - if task_id in batch_check.get('queue', []): - batch_id_for_retry = bid - break - if batch_id_for_retry: - _handle_failed_download(batch_id_for_retry, task_id, task, task_status) + # UNIFIED ERROR HANDLING: Let monitor handle errors for consistency + # Monitor will detect errored state and trigger retry within 5 seconds + print(f"πŸ” Task {task_id} API shows error state: {state_str} - letting monitor handle retry") + + # Keep task in current status (downloading/queued) so monitor can detect error + # Don't mark as failed here - let the unified retry system handle it + if task['status'] in ['searching', 'downloading', 'queued']: + task_status['status'] = task['status'] # Keep current status for monitor else: - # Fallback if batch not found - task_status['status'] = 'failed' - task['status'] = 'failed' + task_status['status'] = 'downloading' # Default to downloading for error detection + task['status'] = 'downloading' elif 'Completed' in state_str or 'Succeeded' in state_str: # NEW VERIFICATION WORKFLOW: Use intermediate post_processing status # Only set this status once to prevent multiple worker submissions - if task['status'] != 'post_processing': + if task['status'] != 'post_processing' and not task.get('stream_processed', False): task_status['status'] = 'post_processing' task['status'] = 'post_processing' print(f"πŸ”„ Task {task_id} API reports 'Succeeded' - starting post-processing verification") @@ -6166,8 +6090,13 @@ def _build_batch_status_data(batch_id, batch, live_transfers_lookup): # Submit post-processing worker to verify file and complete the task missing_download_executor.submit(_run_post_processing_worker, task_id, batch_id) else: - # Keep showing post_processing status until worker completes - task_status['status'] = 'post_processing' + # Keep showing post_processing status until worker completes, or mark completed if already stream processed + if task.get('stream_processed', False): + print(f"⏭️ Task {task_id} already processed by stream worker, marking as completed") + task_status['status'] = 'completed' + task['status'] = 'completed' # Update internal status too + else: + task_status['status'] = 'post_processing' elif 'InProgress' in state_str: task_status['status'] = 'downloading' else: @@ -6188,6 +6117,11 @@ def _build_batch_status_data(batch_id, batch, live_transfers_lookup): batch_tasks.sort(key=lambda x: x['track_index']) response_data['tasks'] = batch_tasks + # CRITICAL: Add batch worker management metadata (was missing!) + # This is essential for client-side worker validation and prevents false desync warnings + response_data['active_count'] = batch.get('active_count', 0) + response_data['max_concurrent'] = batch.get('max_concurrent', 3) + # Add wishlist summary if batch is complete (matching sync.py behavior) if response_data["phase"] == 'complete' and 'wishlist_summary' in batch: response_data['wishlist_summary'] = batch['wishlist_summary'] diff --git a/webui/static/script.js b/webui/static/script.js index 975fcca5..caaa10a8 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -290,6 +290,9 @@ document.addEventListener('DOMContentLoaded', function() { updateServiceStatus(); setInterval(updateServiceStatus, 5000); // Every 5 seconds + // Start always-on download polling (batched, minimal overhead) + startGlobalDownloadPolling(); + // Load initial data loadInitialData(); @@ -354,11 +357,9 @@ async function loadPageData(pageId) { stopWishlistCountPolling(); switch (pageId) { case 'dashboard': - stopDownloadPolling(); await loadDashboardData(); break; case 'sync': - stopDownloadPolling(); initializeSyncPage(); await loadSyncData(); break; @@ -368,7 +369,6 @@ async function loadPageData(pageId) { await loadDownloadsData(); break; case 'artists': - stopDownloadPolling(); // Only fully initialize if not already initialized if (!artistsPageState.isInitialized) { initializeArtistsPage(); @@ -379,7 +379,6 @@ async function loadPageData(pageId) { break; case 'settings': initializeSettings(); - stopDownloadPolling(); await loadSettingsData(); break; } @@ -3446,8 +3445,7 @@ function startGlobalDownloadPolling() { }); if (activeBatchIds.length === 0) { - console.log('πŸ›‘ [Global Polling] No active processes, stopping global poller'); - stopGlobalDownloadPolling(); + console.debug('πŸ“Š [Global Polling] No active processes, continuing polling'); return; } @@ -3487,17 +3485,8 @@ function startGlobalDownloadPolling() { globalPollingFailureCount++; if (globalPollingFailureCount >= 5) { - console.error(`🚨 [Global Polling] ${globalPollingFailureCount} consecutive failures, stopping poller`); - stopGlobalDownloadPolling(); - - // Try to restart after a delay - setTimeout(() => { - console.log('πŸ”„ [Global Polling] Attempting to restart after failures'); - if (Object.keys(activeDownloadProcesses).length > 0) { - startGlobalDownloadPolling(); - } - }, 10000); // 10 second delay before restart - return; + console.error(`🚨 [Global Polling] ${globalPollingFailureCount} consecutive failures, continuing with backoff`); + // Don't stop polling - just continue with exponential backoff } // Exponential backoff: increase interval temporarily @@ -3541,8 +3530,7 @@ function startGlobalDownloadPollingWithInterval(interval) { }); if (activeBatchIds.length === 0) { - console.log('πŸ›‘ [Global Polling] No active processes, stopping global poller'); - stopGlobalDownloadPolling(); + console.debug('πŸ“Š [Global Polling] No active processes, continuing polling'); return; } @@ -3582,13 +3570,8 @@ function startGlobalDownloadPollingWithInterval(interval) { globalPollingFailureCount++; if (globalPollingFailureCount >= 5) { - console.error(`🚨 [Global Polling] Too many failures, stopping`); - stopGlobalDownloadPolling(); - setTimeout(() => { - if (Object.keys(activeDownloadProcesses).length > 0) { - startGlobalDownloadPolling(); - } - }, 10000); + console.error(`🚨 [Global Polling] Too many failures, continuing with backoff`); + // Don't stop polling - just continue with exponential backoff } } }, interval); @@ -3702,7 +3685,7 @@ function processModalStatusUpdate(playlistId, data) { case 'pending': statusText = '⏸️ Pending'; break; case 'searching': statusText = 'πŸ” Searching...'; break; case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break; - case 'post_processing': statusText = 'βŒ› Processing...'; break; // NEW VERIFICATION WORKFLOW + case 'post_processing': statusText = 'βŒ› Processing...'; break; // NEW VERIFICATION WORKFLOW - NOT COUNTED AS FINISHED case 'completed': statusText = 'βœ… Completed'; completedCount++; break; case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break; case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break; @@ -3726,9 +3709,10 @@ function processModalStatusUpdate(playlistId, data) { const serverActiveWorkers = data.active_count || 0; const maxWorkers = data.max_concurrent || 3; - // Count actual active workers based on task statuses (including post_processing) + // FIXED: Count actual active workers based on task statuses + // CRITICAL: Don't count post_processing as active workers since they don't consume worker slots const clientActiveWorkers = (data.tasks || []).filter(task => - ['searching', 'downloading', 'queued', 'post_processing'].includes(task.status) && + ['searching', 'downloading', 'queued'].includes(task.status) && !document.querySelector(`tr[data-track-index="${task.track_index}"][data-locally-cancelled="true"]`) ).length; @@ -3750,7 +3734,9 @@ function processModalStatusUpdate(playlistId, data) { document.getElementById(`download-progress-text-${playlistId}`).textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`; document.getElementById(`stat-downloaded-${playlistId}`).textContent = completedCount; - if (data.phase === 'complete' || data.phase === 'error' || (missingCount > 0 && totalFinished >= missingCount)) { + // FIXED: Only trigger completion logic when backend actually reports batch as complete + // Don't assume completion based on task counts - let backend determine when truly complete + if (data.phase === 'complete' || data.phase === 'error') { // Enhanced check for background auto-processing for wishlist const isWishlist = (playlistId === 'wishlist'); const isModalHidden = (process.modalElement && process.modalElement.style.display === 'none'); @@ -3859,8 +3845,8 @@ function checkAndCleanupGlobalPolling() { .some(p => p.batchId && p.status === 'running'); if (!hasActivePolling) { - console.log('🧹 [Cleanup] No more active processes, stopping global polling'); - stopGlobalDownloadPolling(); + console.debug('🧹 [Cleanup] No more active processes, continuing polling'); + // Keep polling active - no need to stop } }