Add per-row cancel + Cancel All to downloads page, fix streaming cancel
Three closely-related changes bundled together. The UI work exposed the backend bug when I tried to cancel a Deezer download and saw it marked cancelled in the DB but continuing in the background. Backend — cancel_task_v2 orchestrator dispatch fix: The slskd-specific cancel block was written back when soulseek_client was a raw SoulseekClient. It was later swapped to DownloadOrchestrator (which doesn't expose .base_url / ._make_request), so the first diagnostic log line crashed with AttributeError. The outer try/except swallowed it, leaving streaming downloads (YouTube / Tidal / Qobuz / HiFi / Deezer / Lidarr) running in the background after the user clicked cancel. Replaced the ~80-line block with a single soulseek_client.cancel_download(download_id, username, remove=True) call — the orchestrator's dispatch picks the right client by username, same path /api/downloads/cancel already uses successfully. Per-row cancel button (fancy): Circular X button on .adl-row for rows in active or queued state. Hidden by default (opacity 0, translateX + scale), fades in + settles on .adl-row:hover with a cubic-bezier overshoot. Own :hover gives a 1.12x scale pop and brighter red glow. Touch devices (@media (hover: none)) keep it visible. Backend: surfaced playlist_id in /api/downloads/all items so the frontend can hit cancel_task_v2 without a second lookup. Frontend: adlCancelRow(btnEl, playlistId, trackIndex) with double-click guard via data-cancelling + adl-row-cancel-pending class. Cancel All header button: Red-themed button next to "Clear Completed". Only visible when any task is in downloading / searching / post_processing / queued state — auto-hides the moment the last one finishes. Confirm dialog shows "Cancel N tasks across M batches?". Iterates _adlBatches, calls /api/playlists/<batch_id>/cancel_batch sequentially (same endpoint each modal's "Cancel All" and the per-batch-card cancel use). Disables during the loop, mixed/success/error toast based on result. All 276 tests pass.
This commit is contained in:
parent
b79162f9e0
commit
b9a7ed97be
4 changed files with 256 additions and 79 deletions
104
web_server.py
104
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:
|
||||
|
|
|
|||
|
|
@ -2355,6 +2355,7 @@
|
|||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<span class="adl-count" id="adl-count"></span>
|
||||
<button class="adl-cancel-all-btn" id="adl-cancel-all-btn" onclick="adlCancelAll()" style="display:none" title="Cancel all active and queued downloads">Cancel All</button>
|
||||
<button class="adl-clear-btn" id="adl-clear-btn" onclick="adlClearCompleted()" style="display:none">Clear Completed</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
? `<div class="adl-row-batch-color" style="background:rgba(var(--batch-color-${colorIdx}),0.6)"></div>`
|
||||
: '';
|
||||
|
||||
// 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
|
||||
? `<button class="adl-row-cancel" onclick="event.stopPropagation(); adlCancelRow(this, '${_adlEsc(dl.playlist_id)}', ${dl.track_index})" title="Cancel this download" aria-label="Cancel download">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
html += `<div class="adl-row adl-row-${statusClass}" data-task-id="${dl.task_id}" data-batch-id="${dl.batch_id || ''}">
|
||||
${colorBar}
|
||||
${artHtml}
|
||||
|
|
@ -77283,6 +77301,7 @@ function _adlRender() {
|
|||
<span class="adl-status-dot ${statusClass}"></span>
|
||||
${statusLabel}
|
||||
</div>
|
||||
${cancelBtnHtml}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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/<batch_id>/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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue