diff --git a/web_server.py b/web_server.py index 5198ef2d..37202025 100644 --- a/web_server.py +++ b/web_server.py @@ -5085,12 +5085,25 @@ def _start_next_batch_of_downloads(batch_id): while active_count < max_concurrent and queue_index < len(queue): task_id = queue[queue_index] - # IMPORTANT: Set status to 'searching' BEFORE starting worker (like GUI) - # Must be done INSIDE the lock to prevent race conditions with status polling + # CRITICAL V2 FIX: Skip cancelled tasks instead of trying to restart them if task_id in download_tasks: + current_status = download_tasks[task_id]['status'] + if current_status == 'cancelled': + print(f"⏭️ [Batch Lock] Skipping cancelled task {task_id} (queue position {queue_index + 1})") + download_batches[batch_id]['queue_index'] += 1 + queue_index += 1 + continue # Skip to next task without consuming worker slot + + # IMPORTANT: Set status to 'searching' BEFORE starting worker (like GUI) + # Must be done INSIDE the lock to prevent race conditions with status polling download_tasks[task_id]['status'] = 'searching' download_tasks[task_id]['status_change_time'] = time.time() print(f"πŸ”§ [Batch Manager] Set task {task_id} status to 'searching'") + else: + print(f"⚠️ [Batch Lock] Task {task_id} not found in download_tasks - skipping") + download_batches[batch_id]['queue_index'] += 1 + queue_index += 1 + continue # CRITICAL FIX: Submit to executor BEFORE incrementing counters to prevent ghost workers try: @@ -5930,8 +5943,15 @@ def _download_track_worker(task_id, batch_id=None): return if download_tasks[task_id]['status'] == 'cancelled': print(f"❌ [Modal Worker] Task {task_id} cancelled before starting") - # Free worker slot when cancelled - if batch_id: + # V2 FIX: Don't call _on_download_completed for cancelled V2 tasks + # V2 system handles worker slot freeing in atomic cancel function + task_playlist_id = download_tasks[task_id].get('playlist_id') + if task_playlist_id: + print(f"⏭️ [Modal Worker] V2 task {task_id} cancelled - worker slot already freed by V2 system") + return # V2 system already handled worker slot management + elif batch_id: + # Legacy system - use old completion callback + print(f"⏭️ [Modal Worker] Legacy task {task_id} cancelled - using legacy completion callback") _on_download_completed(batch_id, task_id, success=False) return @@ -6457,7 +6477,12 @@ def _build_batch_status_data(batch_id, batch, live_transfers_lookup): 'track_index': task['track_index'], 'status': task['status'], 'track_info': task['track_info'], - 'progress': 0 + 'progress': 0, + # V2 SYSTEM: Add persistent state information + 'cancel_requested': task.get('cancel_requested', False), + 'cancel_timestamp': task.get('cancel_timestamp'), + 'ui_state': task.get('ui_state', 'normal'), # normal|cancelling|cancelled + 'playlist_id': task.get('playlist_id'), # For V2 system identification } task_filename = task.get('filename') or task['track_info'].get('filename') task_username = task.get('username') or task['track_info'].get('username') @@ -6788,6 +6813,400 @@ def cancel_download_task(): except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 +# =============================== +# NEW ATOMIC CANCEL SYSTEM V2 +# =============================== + +def _find_task_by_playlist_track(playlist_id, track_index): + """ + Find task_id by playlist_id and track_index. + This enables the new v2 API to work without requiring task_id from frontend. + """ + for task_id, task in download_tasks.items(): + if (task.get('playlist_id') == playlist_id and + task.get('track_index') == track_index): + return task_id, task + return None, None + +def _atomic_cancel_task(playlist_id, track_index): + """ + Atomically cancel a single task with proper worker slot management. + This is the core of the new cancel system - everything in one transaction. + Returns: (success: bool, message: str, task_info: dict) + """ + try: + # Find the task to cancel + task_id, task = _find_task_by_playlist_track(playlist_id, track_index) + if not task_id: + return False, f"Task not found for playlist {playlist_id}, track {track_index}", None + + # Check if already cancelled + if task.get('status') == 'cancelled': + return False, "Task already cancelled", {'task_id': task_id, 'status': 'cancelled'} + + current_status = task.get('status', 'unknown') + batch_id = task.get('batch_id') + + print(f"🎯 [Atomic Cancel] Starting atomic cancel: playlist={playlist_id}, track={track_index}, task={task_id}, status={current_status}") + + # Mark task as cancelled immediately (within same lock context) + task['status'] = 'cancelled' + task['cancel_requested'] = True + task['cancel_timestamp'] = __import__('time').time() + task['ui_state'] = 'cancelled' + + # Ensure task has persistent identifiers for V2 system + if 'playlist_id' not in task: + task['playlist_id'] = playlist_id + + # Handle worker slot management + worker_slot_freed = False + if batch_id and batch_id in download_batches: + batch = download_batches[batch_id] + active_count = batch['active_count'] + + # Free worker slot if task was consuming one + # More precise check: only free if task was actually running + if active_count > 0 and current_status in ['pending', 'searching', 'downloading', 'queued']: + print(f"πŸ”„ [Atomic Cancel] Freeing worker slot for {task_id} (was {current_status})") + + # CRITICAL: Direct worker slot management to prevent _on_download_completed race + old_active = batch['active_count'] + batch['active_count'] = max(0, old_active - 1) # Prevent negative counts + worker_slot_freed = True + + print(f"πŸ”„ [Atomic Cancel] Worker count: {old_active} β†’ {batch['active_count']}") + + # Try to start next task if available (still within lock) + if (batch['queue_index'] < len(batch['queue']) and + batch['active_count'] < batch['max_concurrent']): + print(f"πŸš€ [Atomic Cancel] Starting next task in queue") + # Call the existing function to start next downloads + # Note: This will be called outside the lock to prevent deadlock + else: + print(f"🚫 [Atomic Cancel] No next task to start (queue_index: {batch['queue_index']}/{len(batch['queue'])}, active: {batch['active_count']}/{batch['max_concurrent']})") + + # Build result info + task_info = { + 'task_id': task_id, + 'status': 'cancelled', + 'track_name': task.get('track_info', {}).get('name', 'Unknown'), + 'playlist_id': playlist_id, + 'track_index': track_index, + 'worker_slot_freed': worker_slot_freed + } + + print(f"βœ… [Atomic Cancel] Successfully cancelled task {task_id}") + return True, "Task cancelled successfully", task_info + + except Exception as e: + print(f"❌ [Atomic Cancel] Error in atomic cancel: {e}") + import traceback + traceback.print_exc() + return False, f"Internal error: {str(e)}", None + +@app.route('/api/downloads/cancel_task_v2', methods=['POST']) +def cancel_task_v2(): + """ + NEW ATOMIC CANCEL SYSTEM V2 + + Accepts playlist_id and track_index instead of task_id. + Performs atomic cancellation with proper worker slot management. + No race conditions, no dual state management. + """ + data = request.get_json() + playlist_id = data.get('playlist_id') + track_index = data.get('track_index') + + if not playlist_id or track_index is None: + return jsonify({ + "success": False, + "error": "Missing playlist_id or track_index" + }), 400 + + try: + # Everything in one atomic operation within the lock + with tasks_lock: + success, message, task_info = _atomic_cancel_task(playlist_id, track_index) + + if not success: + return jsonify({"success": False, "error": message}), 400 + + # Handle post-cancel operations (outside the lock to prevent deadlock) + task_id = task_info['task_id'] + task = download_tasks.get(task_id) + + # Try to start next batch of downloads (this may start new workers) + if task and task.get('batch_id'): + batch_id = task['batch_id'] + # Call existing function to manage batch progression + try: + _start_next_batch_of_downloads(batch_id) + except Exception as e: + print(f"⚠️ [Atomic Cancel] Warning: Could not start next downloads: {e}") + + # CRITICAL: Check for batch completion after V2 cancel + # V2 system bypasses _on_download_completed, so we need to check completion manually + try: + _check_batch_completion_v2(batch_id) + except Exception as e: + print(f"⚠️ [Atomic Cancel] Warning: Could not check batch completion: {e}") + + # Cancel Soulseek download if active (non-blocking) + if task: + download_id = task.get('download_id') + username = task.get('username') + current_status = task.get('status') + + print(f"πŸ” [Atomic Cancel] Task {task_id} state: status='{current_status}', download_id='{download_id}', username='{username}'") + print(f"πŸ” [Atomic Cancel] Download ID type: {type(download_id)}, length: {len(str(download_id)) if download_id else 0}") + print(f"πŸ” [Atomic Cancel] Download ID looks like filename: {download_id and ('/' in str(download_id) or '\\' in str(download_id))}") + + if download_id and username: + # Only try to cancel if task was actually downloading (has real slskd download) + # For pending/searching tasks, the cancellation is already handled by status='cancelled' + if current_status in ['downloading', 'queued']: # Remove 'searching' - those aren't in slskd yet + try: + print(f"🚫 [Atomic Cancel] Attempting to cancel Soulseek download:") + print(f" Username: {username}") + print(f" Download ID: {download_id}") + print(f" Base URL: {soulseek_client.base_url}") + print(f" Expected URL: {soulseek_client.base_url}/transfers/downloads/{username}/{download_id}?remove=true") + + # CRITICAL: Must use REAL download ID from slskd, not filename + success = False + real_download_id = None + + # Step 1: Always search for real download ID first + print(f"πŸ” [Atomic Cancel] Searching slskd transfers for real download ID") + try: + all_transfers = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads')) + if all_transfers: + # Look through transfers to find matching download + for user_data in all_transfers: + if user_data.get('username') == username: + for directory in user_data.get('directories', []): + for file_data in directory.get('files', []): + file_filename = file_data.get('filename', '') + # Match by filename (our download_id might be filename) + if (file_filename == download_id or + __import__('os').path.basename(file_filename) == __import__('os').path.basename(str(download_id))): + real_download_id = file_data.get('id') + print(f"🎯 [Atomic Cancel] Found real download ID: {real_download_id} for file: {file_filename}") + break + if real_download_id: + break + if real_download_id: + break + except Exception as search_error: + print(f"⚠️ [Atomic Cancel] Error searching transfers: {search_error}") + + # Step 2: Try cancellation with real ID if found + if real_download_id: + print(f"πŸ”„ [Atomic Cancel] Attempting cancel with real ID: {real_download_id}") + try: + # Use both approaches that work + # Approach A: Simple DELETE (like sync.py) + response = asyncio.run(soulseek_client._make_request('DELETE', f'transfers/downloads/{real_download_id}')) + if response is not None: + print(f"βœ… [Atomic Cancel] Successfully cancelled with simple DELETE: {real_download_id}") + success = True + else: + # Approach B: Complex method (like downloads.py) + print(f"πŸ”„ [Atomic Cancel] Simple DELETE failed, trying complex method") + complex_success = asyncio.run(soulseek_client.cancel_download(real_download_id, username, remove=True)) + if complex_success: + print(f"βœ… [Atomic Cancel] Successfully cancelled with complex method: {real_download_id}") + success = True + else: + print(f"⚠️ [Atomic Cancel] Both methods failed for real ID: {real_download_id}") + except Exception as cancel_error: + print(f"⚠️ [Atomic Cancel] Exception cancelling real ID {real_download_id}: {cancel_error}") + else: + print(f"⚠️ [Atomic Cancel] Could not find real download ID in slskd transfers") + print(f"πŸ”„ [Atomic Cancel] This might be a pending download not yet in slskd - relying on status='cancelled' to prevent it") + # For pending downloads, the status='cancelled' will prevent them from starting + success = True # Consider this success since pending downloads are prevented + + if not success: + print(f"❌ [Atomic Cancel] Failed to cancel download in slskd API") + except Exception as e: + print(f"⚠️ [Atomic Cancel] Exception cancelling Soulseek download {download_id}: {e}") + # Print more details about the error + import traceback + print(f"⚠️ [Atomic Cancel] Cancel error traceback: {traceback.format_exc()}") + else: + print(f"⏭️ [Atomic Cancel] Skipping Soulseek cancel - task was '{current_status}' (not actively downloading)") + elif current_status in ['downloading', 'queued', 'searching']: + print(f"⚠️ [Atomic Cancel] Task was '{current_status}' but missing download_id or username for Soulseek cancel") + else: + print(f"ℹ️ [Atomic Cancel] No Soulseek cancel needed - task was '{current_status}'") + + # Add to wishlist (non-blocking, best effort) + try: + _add_cancelled_task_to_wishlist(task) + except Exception as e: + print(f"⚠️ [Atomic Cancel] Warning: Could not add to wishlist: {e}") + + return jsonify({ + "success": True, + "message": message, + "task_info": { + 'task_id': task_info['task_id'], + 'track_name': task_info['track_name'], + 'status': 'cancelled' + } + }) + + except Exception as e: + print(f"❌ [Cancel V2] Unexpected error: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + +def _check_batch_completion_v2(batch_id): + """ + V2 SYSTEM: Check if batch is complete after worker slot changes. + + This is needed because V2 atomic cancel bypasses _on_download_completed, + so we need to manually check for batch completion. + """ + try: + with tasks_lock: + if batch_id not in download_batches: + print(f"⚠️ [Completion Check V2] Batch {batch_id} not found") + return + + batch = download_batches[batch_id] + all_tasks_started = batch['queue_index'] >= len(batch['queue']) + no_active_workers = batch['active_count'] == 0 + + # Count actually finished tasks (completed, failed, or cancelled) + 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 + + print(f"πŸ” [Completion Check V2] Batch {batch_id}: tasks_started={all_tasks_started}, workers={no_active_workers}, finished={finished_count}/{len(queue)}, retrying={retrying_count}") + + if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks: + print(f"πŸŽ‰ [Completion Check V2] Batch {batch_id} is complete - marking as finished") + + # Check if this is an auto-initiated batch + is_auto_batch = batch.get('auto_initiated', False) + + # Mark batch as complete + batch['phase'] = 'complete' + + # Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist + playlist_id = batch.get('playlist_id') + if playlist_id and playlist_id.startswith('youtube_'): + url_hash = playlist_id.replace('youtube_', '') + if url_hash in youtube_playlist_states: + youtube_playlist_states[url_hash]['phase'] = 'download_complete' + print(f"πŸ“‹ [Completion Check V2] Updated YouTube playlist {url_hash} to download_complete phase") + + # Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist + if playlist_id and playlist_id.startswith('tidal_'): + tidal_playlist_id = playlist_id.replace('tidal_', '') + if tidal_playlist_id in tidal_discovery_states: + tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete' + print(f"πŸ“‹ [Completion Check V2] Updated Tidal playlist {tidal_playlist_id} to download_complete phase") + + print(f"πŸŽ‰ [Completion Check V2] Batch {batch_id} complete - stopping monitor") + download_monitor.stop_monitoring(batch_id) + + # Process wishlist outside of the lock to prevent threading issues + if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks: + # Call wishlist processing outside the lock + if is_auto_batch: + print(f"πŸ€– [Completion Check V2] Processing auto-initiated batch completion") + # Use the existing auto-completion function + _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id) + else: + print(f"πŸ“‹ [Completion Check V2] Processing regular batch completion") + # Use the regular completion function + _process_failed_tracks_to_wishlist_exact(batch_id) + + return True # Batch was completed + else: + print(f"πŸ“Š [Completion Check V2] Batch {batch_id} not yet complete: finished={finished_count}/{len(queue)}, retrying={retrying_count}, workers={batch['active_count']}") + return False # Batch still in progress + + except Exception as e: + print(f"❌ [Completion Check V2] Error checking batch completion: {e}") + import traceback + traceback.print_exc() + return False + +def _add_cancelled_task_to_wishlist(task): + """ + Helper function to add cancelled task to wishlist. + Separated for clarity and error isolation. + """ + if not task: + return + + try: + from core.wishlist_service import get_wishlist_service + wishlist_service = get_wishlist_service() + + track_info = task.get('track_info', {}) + artists_data = track_info.get('artists', []) + formatted_artists = [] + + for artist in artists_data: + if isinstance(artist, str): + formatted_artists.append({'name': artist}) + elif isinstance(artist, dict): + if 'name' in artist and isinstance(artist['name'], str): + formatted_artists.append(artist) + elif 'name' in artist and isinstance(artist['name'], dict) and 'name' in artist['name']: + formatted_artists.append({'name': artist['name']['name']}) + else: + formatted_artists.append({'name': str(artist)}) + else: + formatted_artists.append({'name': str(artist)}) + + spotify_track_data = { + 'id': track_info.get('id'), + 'name': track_info.get('name'), + 'artists': formatted_artists, + 'album': {'name': track_info.get('album')}, + 'duration_ms': track_info.get('duration_ms') + } + + source_context = { + 'playlist_name': task.get('playlist_name', 'Unknown Playlist'), + 'playlist_id': task.get('playlist_id'), + 'added_from': 'modal_cancellation_v2' + } + + success = wishlist_service.add_spotify_track_to_wishlist( + spotify_track_data=spotify_track_data, + failure_reason="Download cancelled by user (v2)", + source_type="playlist", + source_context=source_context + ) + + if success: + print(f"βœ… [Atomic Cancel] Added '{track_info.get('name')}' to wishlist") + else: + print(f"❌ [Atomic Cancel] Failed to add '{track_info.get('name')}' to wishlist") + + except Exception as e: + print(f"❌ [Atomic Cancel] Critical error adding to wishlist: {e}") + @app.route('/api/playlists//cancel_batch', methods=['POST']) def cancel_batch(batch_id): """ diff --git a/webui/static/script.js b/webui/static/script.js index d0b0a726..63ffae4f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -3759,36 +3759,63 @@ function processModalStatusUpdate(playlistId, data) { return; } - // Stronger protection: Don't override locally cancelled tracks with any backend updates - if (row.dataset.locallyCancelled === 'true') { + // V2 SYSTEM: Check for persistent cancel state from backend + const isV2Task = task.playlist_id !== undefined; // V2 tasks have playlist_id + const cancelRequested = task.cancel_requested || false; + const uiState = task.ui_state || 'normal'; + + // Legacy protection for old system compatibility + if (row.dataset.locallyCancelled === 'true' && !isV2Task) { failedOrCancelledCount++; - return; // Completely skip processing this task to avoid any UI conflicts + return; // Only skip for legacy system tasks + } + + // Mark row with V2 system info + if (isV2Task) { + row.dataset.useV2System = 'true'; + row.dataset.cancelRequested = cancelRequested.toString(); + row.dataset.uiState = uiState; } row.dataset.taskId = task.task_id; const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`); const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`); + let statusText = ''; - switch (task.status) { - 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 - NOT COUNTED AS FINISHED - case 'completed': statusText = 'βœ… Completed'; completedCount++; break; - case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break; - case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break; - default: statusText = `βšͺ ${task.status}`; break; + // V2 SYSTEM: Handle UI state override for cancelling tasks + if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') { + statusText = 'πŸ”„ Cancelling...'; + } else { + switch (task.status) { + 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; + case 'completed': statusText = 'βœ… Completed'; completedCount++; break; + case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break; + case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break; + default: statusText = `βšͺ ${task.status}`; break; + } } + if(statusEl) { statusEl.textContent = statusText; - console.debug(`βœ… [Status Update] Updated track ${task.track_index} to: ${statusText}`); + console.debug(`βœ… [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`); } else { console.warn(`❌ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`); } - if (actionsEl && !['completed', 'failed', 'cancelled', 'post_processing'].includes(task.status) && actionsEl.innerHTML === '-') { - actionsEl.innerHTML = ``; - } - if (actionsEl && ['completed', 'failed', 'cancelled', 'post_processing'].includes(task.status)) { + + // V2 SYSTEM: Smart button management with persistent state awareness + if (actionsEl && !['completed', 'failed', 'cancelled', 'post_processing'].includes(task.status)) { + // Check if we're in a cancelling state + if (isV2Task && uiState === 'cancelling') { + actionsEl.innerHTML = 'Cancelling...'; + } else { + // Create V2 cancel button for all active tasks + const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload'; + actionsEl.innerHTML = ``; + } + } else if (actionsEl && ['completed', 'failed', 'cancelled', 'post_processing'].includes(task.status)) { actionsEl.innerHTML = '-'; // No actions available for terminal or processing states } }); @@ -3797,12 +3824,13 @@ function processModalStatusUpdate(playlistId, data) { const serverActiveWorkers = data.active_count || 0; const maxWorkers = data.max_concurrent || 3; - // 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'].includes(task.status) && - !document.querySelector(`tr[data-track-index="${task.track_index}"][data-locally-cancelled="true"]`) - ).length; + // V2 SYSTEM: Simplified worker counting - backend is authoritative + // Count active tasks, excluding locally cancelled legacy tasks only + const clientActiveWorkers = (data.tasks || []).filter(task => { + const row = document.querySelector(`tr[data-track-index="${task.track_index}"]`); + const isLegacyCancelled = row && row.dataset.locallyCancelled === 'true' && !row.dataset.useV2System; + return ['searching', 'downloading', 'queued'].includes(task.status) && !isLegacyCancelled; + }).length; // Log discrepancies for debugging if (serverActiveWorkers !== clientActiveWorkers) { @@ -4134,6 +4162,108 @@ function resetToInitialState() { missingTracks = []; } +// =============================== +// NEW ATOMIC CANCEL SYSTEM V2 +// =============================== + +async function cancelTrackDownloadV2(playlistId, trackIndex) { + /** + * NEW ATOMIC CANCEL SYSTEM V2 + * + * - No optimistic UI updates + * - Single API call handles everything atomically + * - Backend is single source of truth for all state + * - No race conditions or dual state management + */ + const process = activeDownloadProcesses[playlistId]; + if (!process) { + console.warn(`❌ [Cancel V2] No process found for playlist: ${playlistId}`); + return; + } + + const row = document.querySelector(`#download-missing-modal-${playlistId} tr[data-track-index="${trackIndex}"]`); + if (!row) { + console.warn(`❌ [Cancel V2] No row found for track index: ${trackIndex}`); + return; + } + + // Check if already in cancelling state + const statusEl = document.getElementById(`download-${playlistId}-${trackIndex}`); + const currentStatus = statusEl ? statusEl.textContent : ''; + + if (currentStatus.includes('Cancelling') || currentStatus.includes('Cancelled')) { + console.log(`⚠️ [Cancel V2] Task already being cancelled or cancelled: ${currentStatus}`); + return; + } + + console.log(`🎯 [Cancel V2] Starting atomic cancel: playlist=${playlistId}, track=${trackIndex}`); + + // V2 SYSTEM: Set temporary UI state - will be confirmed by server + row.dataset.uiState = 'cancelling'; + + // Show loading state only - no optimistic "cancelled" state + if (statusEl) { + statusEl.textContent = 'πŸ”„ Cancelling...'; + } + + // Disable the cancel button to prevent double-clicks + const actionsEl = document.getElementById(`actions-${playlistId}-${trackIndex}`); + if (actionsEl) { + actionsEl.innerHTML = 'Cancelling...'; + } + + try { + const response = await fetch('/api/downloads/cancel_task_v2', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_id: playlistId, + track_index: trackIndex + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`βœ… [Cancel V2] Successfully cancelled: ${data.task_info.track_name}`); + showToast(`Cancelled "${data.task_info.track_name}" and added to wishlist.`, 'success'); + + // Let the status polling system update the UI with server truth + // No manual UI updates - backend is authoritative + + } else { + console.error(`❌ [Cancel V2] Cancel failed: ${data.error}`); + showToast(`Cancel failed: ${data.error}`, 'error'); + + // Reset UI to previous state on failure + row.dataset.uiState = 'normal'; // Reset UI state + if (statusEl) { + statusEl.textContent = '❌ Cancel Failed'; + } + if (actionsEl) { + actionsEl.innerHTML = ``; + } + } + + } catch (error) { + console.error(`❌ [Cancel V2] Network/API error:`, error); + showToast(`Cancel request failed: ${error.message}`, 'error'); + + // Reset UI on network error + row.dataset.uiState = 'normal'; // Reset UI state + if (statusEl) { + statusEl.textContent = '❌ Cancel Failed'; + } + if (actionsEl) { + actionsEl.innerHTML = ``; + } + } +} + +// =============================== +// LEGACY CANCEL SYSTEM (OLD) +// =============================== + async function cancelTrackDownload(playlistId, trackIndex) { const process = activeDownloadProcesses[playlistId]; if (!process) return; @@ -5626,7 +5756,8 @@ window.openDownloadMissingModal = openDownloadMissingModal; window.closeDownloadMissingModal = closeDownloadMissingModal; window.startMissingTracksProcess = startMissingTracksProcess; window.cancelAllOperations = cancelAllOperations; -window.cancelTrackDownload = cancelTrackDownload; +window.cancelTrackDownload = cancelTrackDownload; // Legacy system +window.cancelTrackDownloadV2 = cancelTrackDownloadV2; // NEW V2 system window.handleViewProgressClick = handleViewProgressClick; // Wishlist Modal functions