Implement atomic cancel system V2 for downloads

Introduces a new atomic cancel system (V2) for download tasks, ensuring single-step cancellation with proper worker slot management and backend-driven state. Updates both backend (web_server.py) and frontend (script.js) to support V2 cancel API, persistent cancel state, and improved UI handling for cancelling tasks, eliminating race conditions and dual state management.
This commit is contained in:
Broque Thomas 2025-09-09 16:39:01 -07:00
parent 184c38bbb7
commit db26ab8b15
2 changed files with 579 additions and 29 deletions

View file

@ -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/<batch_id>/cancel_batch', methods=['POST'])
def cancel_batch(batch_id):
"""

View file

@ -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 = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownload('${playlistId}', ${task.track_index})">×</button>`;
}
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 = '<span style="color: #666;">Cancelling...</span>';
} else {
// Create V2 cancel button for all active tasks
const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload';
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="${onclickHandler}('${playlistId}', ${task.track_index})">×</button>`;
}
} 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 = '<span style="color: #666;">Cancelling...</span>';
}
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 = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownloadV2('${playlistId}', ${trackIndex})">×</button>`;
}
}
} 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 = `<button class="cancel-track-btn" title="Cancel this download" onclick="cancelTrackDownloadV2('${playlistId}', ${trackIndex})">×</button>`;
}
}
}
// ===============================
// 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