diff --git a/web_server.py b/web_server.py index ec7505e0..4cc396cc 100644 --- a/web_server.py +++ b/web_server.py @@ -31261,6 +31261,10 @@ def get_all_downloads_unified(): 'batch_id': batch_id, 'batch_name': batch.get('playlist_name') or batch.get('album_name') or '', 'batch_source': batch.get('source_page') or batch.get('initiated_from') or '', + # playlist_id is needed by per-row cancel (cancel_task_v2 + # takes playlist_id + track_index). Surfacing it here so + # the frontend doesn't need a second lookup. + 'playlist_id': batch.get('playlist_id', ''), 'track_index': task.get('track_index', 0), 'batch_total': len(batch.get('queue', [])), 'timestamp': task.get('status_change_time', 0), @@ -31665,90 +31669,32 @@ def cancel_task_v2(): logger.info(f"[Atomic Cancel] Download ID looks like filename: {download_id and ('/' in str(download_id) or backslash in str(download_id))}") if download_id and username: - # Always try to cancel in slskd - doesn't matter what status it was - # If it's not there or already done, the DELETE request will just fail harmlessly + # Route through the DownloadOrchestrator's dispatch (same code + # path /api/downloads/cancel uses). It picks the right client by + # username: youtube/tidal/qobuz/hifi/deezer_dl/lidarr go to + # their streaming clients, anything else goes to Soulseek. + # + # Replaces an older block that assumed soulseek_client was a + # raw SoulseekClient and accessed .base_url / ._make_request + # directly — crashed with AttributeError on the orchestrator + # and silently left streaming downloads running in background. try: - logger.info("[Atomic Cancel] Attempting to cancel Soulseek download:") - logger.info(f" Username: {username}") - logger.info(f" Download ID: {download_id}") - logger.info(f" Base URL: {soulseek_client.base_url}") - logger.info(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 - logger.info("[Atomic Cancel] Searching slskd transfers for real download ID") - try: - all_transfers = run_async(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') - logger.info(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: - logger.error(f"[Atomic Cancel] Error searching transfers: {search_error}") - - # Step 2: Try cancellation with real ID if found - if real_download_id: - logger.info(f"[Atomic Cancel] Attempting cancel with real ID: {real_download_id}") - try: - # Use EXACT format from slskd web UI: DELETE /api/v0/transfers/downloads/{username}/{download_id}?remove=false - endpoint = f'transfers/downloads/{username}/{real_download_id}?remove=true' - logger.info(f"[Atomic Cancel] Using slskd web UI format: {endpoint}") - - response = run_async(soulseek_client._make_request('DELETE', endpoint)) - if response is not None: - logger.warning(f"[Atomic Cancel] Successfully cancelled with slskd web UI format: {real_download_id}") - success = True - else: - logger.error("[Atomic Cancel] Web UI format failed, trying alternative formats") - - # Fallback: Try without remove parameter - endpoint2 = f'transfers/downloads/{username}/{real_download_id}' - response2 = run_async(soulseek_client._make_request('DELETE', endpoint2)) - if response2 is not None: - logger.warning(f"[Atomic Cancel] Successfully cancelled without remove param: {real_download_id}") - success = True - else: - # Final fallback: Try simple format (sync.py style) - endpoint3 = f'transfers/downloads/{real_download_id}' - response3 = run_async(soulseek_client._make_request('DELETE', endpoint3)) - if response3 is not None: - logger.warning(f"[Atomic Cancel] Successfully cancelled with simple format: {real_download_id}") - success = True - else: - logger.error(f"[Atomic Cancel] All DELETE formats failed for real ID: {real_download_id}") - except Exception as cancel_error: - logger.error(f"[Atomic Cancel] Exception cancelling real ID {real_download_id}: {cancel_error}") - else: - logger.error("[Atomic Cancel] Could not find real download ID in slskd transfers") - logger.warning("[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: - logger.error("[Atomic Cancel] Failed to cancel download in slskd API") + logger.info(f"[Atomic Cancel] Dispatching cancel to orchestrator: username={username} download_id={download_id}") + cancel_success = run_async( + soulseek_client.cancel_download(download_id, username, remove=True) + ) + if cancel_success: + logger.info(f"[Atomic Cancel] Orchestrator cancelled download: {download_id}") + else: + # Non-fatal: task is already marked cancelled in the DB. + # Streaming workers also poll status='cancelled' and bail. + logger.warning(f"[Atomic Cancel] Orchestrator could not cancel {download_id} (likely already finished or not yet started)") except Exception as e: - logger.error(f"[Atomic Cancel] Exception cancelling Soulseek download {download_id}: {e}") - # Print more details about the error + logger.error(f"[Atomic Cancel] Exception cancelling download {download_id}: {e}") import traceback logger.error(f"[Atomic Cancel] Cancel error traceback: {traceback.format_exc()}") else: - logger.warning("ℹ️ [Atomic Cancel] No download_id or username available - skipping slskd cancel") + logger.warning("ℹ️ [Atomic Cancel] No download_id or username available - skipping cancel dispatch") # Add to wishlist (non-blocking, best effort) try: diff --git a/webui/index.html b/webui/index.html index 46ce6dc6..025bf1fa 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2355,6 +2355,7 @@
+
diff --git a/webui/static/script.js b/webui/static/script.js index fd022f02..3fded92f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -77196,6 +77196,15 @@ function _adlRender() { const clearBtn = document.getElementById('adl-clear-btn'); if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none'; + // Show/hide cancel-all button — only visible when there's something to cancel + const cancelAllBtn = document.getElementById('adl-cancel-all-btn'); + if (cancelAllBtn) { + const hasRunningWork = _adlData.some(d => + [...activeStatuses, ...queuedStatuses].includes(d.status) + ); + cancelAllBtn.style.display = hasRunningWork ? '' : 'none'; + } + // Batch filter indicator banner let existingBanner = document.getElementById('adl-batch-filter-banner'); if (_adlFilterBatchId) { @@ -77270,6 +77279,15 @@ function _adlRender() { ? `
` : ''; + // Per-row cancel only makes sense for in-flight tasks. Terminal + // states (completed/failed/cancelled) have nothing to cancel. + const isCancellable = statusClass === 'active' || statusClass === 'queued'; + const cancelBtnHtml = isCancellable && dl.playlist_id && dl.track_index !== undefined + ? `` + : ''; + html += `
${colorBar} ${artHtml} @@ -77283,6 +77301,7 @@ function _adlRender() { ${statusLabel}
+ ${cancelBtnHtml} `; } } @@ -77558,6 +77577,50 @@ function _adlFilterByBatch(batchId) { _adlRenderBatchPanel(); } +async function adlCancelRow(btnEl, playlistId, trackIndex) { + // Per-row cancel on the Downloads page. Uses the same atomic cancel + // endpoint the modal cancel buttons use, so worker slots free properly. + if (!playlistId || trackIndex === undefined || trackIndex === null) { + showToast('Cannot cancel — missing task coordinates', 'error'); + return; + } + // Lock the button so rapid clicks don't fire duplicate requests + if (btnEl) { + if (btnEl.dataset.cancelling === '1') return; + btnEl.dataset.cancelling = '1'; + btnEl.classList.add('adl-row-cancel-pending'); + } + try { + const resp = 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 resp.json(); + if (data.success) { + const name = data.task_info && data.task_info.track_name ? data.task_info.track_name : 'Track'; + showToast(`Cancelled "${name}"`, 'info'); + _adlFetch(); + } else { + showToast(data.error || 'Cancel failed', 'error'); + if (btnEl) { + btnEl.dataset.cancelling = '0'; + btnEl.classList.remove('adl-row-cancel-pending'); + } + } + } catch (e) { + console.error('ADL row cancel error:', e); + showToast('Cancel request failed', 'error'); + if (btnEl) { + btnEl.dataset.cancelling = '0'; + btnEl.classList.remove('adl-row-cancel-pending'); + } + } +} + async function _adlCancelBatch(batchId) { const batch = _adlBatches.find(b => b.batch_id === batchId); const batchName = batch ? batch.batch_name : 'this batch'; @@ -77582,6 +77645,70 @@ async function _adlCancelBatch(batchId) { } } +async function adlCancelAll() { + // Cancel every batch with active/queued work — equivalent to clicking + // "Cancel All" inside each running download modal. Uses the same + // /api/playlists//cancel_batch endpoint the per-batch card + // cancel uses, so worker slots free atomically. + const runningBatches = _adlBatches.filter(b => (b.active || 0) > 0 || (b.queued || 0) > 0); + if (runningBatches.length === 0) { + showToast('No active batches to cancel', 'info'); + return; + } + + const totalTasks = runningBatches.reduce((sum, b) => sum + (b.active || 0) + (b.queued || 0), 0); + const batchWord = runningBatches.length === 1 ? 'batch' : 'batches'; + const taskWord = totalTasks === 1 ? 'task' : 'tasks'; + const confirmed = await showConfirmDialog({ + title: 'Cancel All Downloads', + message: `Cancel ${totalTasks} ${taskWord} across ${runningBatches.length} ${batchWord}? Active and queued downloads will be stopped and added to the wishlist.`, + confirmText: 'Cancel All', + destructive: true + }); + if (!confirmed) return; + + const btn = document.getElementById('adl-cancel-all-btn'); + if (btn) { + btn.disabled = true; + btn.classList.add('adl-cancel-all-pending'); + } + + let cancelled = 0; + let failed = 0; + // Sequential so we don't hammer the backend — cancel_batch takes a lock + // internally and parallel calls would mostly serialize anyway. + for (const batch of runningBatches) { + try { + const resp = await fetch(`/api/playlists/${batch.batch_id}/cancel_batch`, { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + cancelled += (data.cancelled_tasks || 0); + } else { + failed += 1; + console.warn(`cancel_batch failed for ${batch.batch_id}:`, data.error); + } + } catch (e) { + failed += 1; + console.warn(`cancel_batch exception for ${batch.batch_id}:`, e); + } + } + + if (btn) { + btn.disabled = false; + btn.classList.remove('adl-cancel-all-pending'); + } + + if (cancelled > 0 && failed === 0) { + showToast(`Cancelled ${cancelled} downloads`, 'success'); + } else if (cancelled > 0 && failed > 0) { + showToast(`Cancelled ${cancelled} downloads (${failed} batches failed)`, 'info'); + } else { + showToast('Failed to cancel any downloads', 'error'); + } + + _adlFetch(); +} + // ---- Batch History ---- async function _adlFetchBatchHistory() { @@ -77664,5 +77791,7 @@ window._adlToggleBatch = _adlToggleBatch; window._adlOpenBatchModal = _adlOpenBatchModal; window._adlFilterByBatch = _adlFilterByBatch; window._adlCancelBatch = _adlCancelBatch; +window.adlCancelRow = adlCancelRow; +window.adlCancelAll = adlCancelAll; window.adlToggleBatchHistory = adlToggleBatchHistory; window.adlToggleBatchPanel = adlToggleBatchPanel; diff --git a/webui/static/style.css b/webui/static/style.css index d77793d7..d71b03ef 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -56399,6 +56399,40 @@ body.reduce-effects *::after { border-color: rgba(255, 255, 255, 0.12); } +/* Cancel All button — appears in the header only when at least one batch has + active or queued work. Same shape as clear-btn, red themed. */ +.adl-cancel-all-btn { + padding: 5px 12px; + border-radius: 7px; + border: 1px solid rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.12); + color: rgba(252, 165, 165, 0.95); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.adl-cancel-all-btn:hover { + background: rgba(239, 68, 68, 0.22); + border-color: rgba(239, 68, 68, 0.6); + color: #fff; + box-shadow: 0 0 12px rgba(239, 68, 68, 0.25); +} + +.adl-cancel-all-btn:active { + transform: scale(0.97); +} + +.adl-cancel-all-btn.adl-cancel-all-pending, +.adl-cancel-all-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + .adl-list { display: flex; flex-direction: column; @@ -56458,6 +56492,73 @@ body.reduce-effects *::after { border-color: rgba(239, 68, 68, 0.1); } +/* ── Per-row cancel button ── + Fancy reveal on hover: fades in + slides/scales from the right edge with + a soft red glow. Only rendered on active/queued rows (set in JS). Terminal + rows have nothing to cancel. +*/ +.adl-row-cancel { + flex-shrink: 0; + width: 28px; + height: 28px; + margin-left: 8px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: linear-gradient(135deg, rgba(239, 68, 68, 0.85), rgba(220, 38, 38, 0.9)); + border: 1px solid rgba(239, 68, 68, 0.55); + color: #fff; + cursor: pointer; + box-shadow: 0 0 10px rgba(239, 68, 68, 0.25), 0 2px 6px rgba(0, 0, 0, 0.25); + opacity: 0; + pointer-events: none; + transform: translateX(8px) scale(0.72); + transition: opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.adl-row-cancel svg { + display: block; +} + +.adl-row:hover .adl-row-cancel, +.adl-row:focus-within .adl-row-cancel { + opacity: 1; + pointer-events: auto; + transform: translateX(0) scale(1); +} + +.adl-row-cancel:hover { + background: linear-gradient(135deg, rgba(248, 113, 113, 0.95), rgba(239, 68, 68, 1)); + border-color: rgba(248, 113, 113, 0.85); + box-shadow: 0 0 14px rgba(239, 68, 68, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3); + transform: translateX(0) scale(1.12); +} + +.adl-row-cancel:active { + transform: translateX(0) scale(0.95); + transition: transform 0.08s ease; +} + +/* Pending state — cancel request in flight; dim the button and disable */ +.adl-row-cancel-pending, +.adl-row-cancel-pending:hover { + opacity: 0.5 !important; + pointer-events: none !important; + transform: translateX(0) scale(1) !important; + box-shadow: none !important; +} + +/* Touch devices: no hover, keep the cancel button visible so it's reachable. */ +@media (hover: none) { + .adl-row-cancel { + opacity: 1; + pointer-events: auto; + transform: translateX(0) scale(1); + } +} + .adl-row-art { width: 44px; height: 44px;