Add error reason tooltips to failed downloads in missing tracks modal
When a download fails in the Download Missing Tracks modal, hovering over the "Failed" status now shows a tooltip explaining why. The backend already tracked error_message on tasks internally but never sent it to the frontend. This surfaces those reasons and enriches them with detailed context — search diagnostics break down what happened per query (no results, filtered out, search error), retry failures include source counts and likely cause, and timeout/stuck messages name the state and duration. The tooltip uses a fixed-position popup to avoid clipping by the modal's scroll container, with scroll and visibility-aware dismissal.
This commit is contained in:
parent
335c9dc977
commit
801274deb3
3 changed files with 138 additions and 13 deletions
|
|
@ -738,9 +738,12 @@ class WebUIDownloadMonitor:
|
|||
return False
|
||||
else:
|
||||
# Too many error retries, mark as failed
|
||||
track_label = task.get('track_info', {}).get('name', 'Unknown')
|
||||
tried_sources = task.get('used_sources', set())
|
||||
sources_str = f' (tried {len(tried_sources)} source{"s" if len(tried_sources) != 1 else ""})' if tried_sources else ''
|
||||
print(f"❌ Task failed after 3 error retry attempts")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = 'Failed after multiple error retries'
|
||||
task['error_message'] = f'Soulseek transfer errored 3 times for "{track_label}"{sources_str} — all sources failed or became unavailable'
|
||||
|
||||
# CRITICAL: Notify batch manager so track is added to permanently_failed_tracks
|
||||
batch_id = task.get('batch_id')
|
||||
|
|
@ -836,9 +839,12 @@ class WebUIDownloadMonitor:
|
|||
return False
|
||||
else:
|
||||
# Too many retries, mark as failed
|
||||
track_label = task.get('track_info', {}).get('name', 'Unknown')
|
||||
tried_sources = task.get('used_sources', set())
|
||||
sources_str = f' (tried {len(tried_sources)} source{"s" if len(tried_sources) != 1 else ""})' if tried_sources else ''
|
||||
print(f"❌ Task failed after 3 retry attempts (queue timeout)")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = 'Failed after multiple queue timeout retries'
|
||||
task['error_message'] = f'Download stayed queued too long 3 times for "{track_label}"{sources_str} — peers may be offline or have full queues'
|
||||
# Clear timers to prevent further retry loops
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
|
|
@ -933,9 +939,12 @@ class WebUIDownloadMonitor:
|
|||
# Wait longer before next retry
|
||||
return False
|
||||
else:
|
||||
track_label = task.get('track_info', {}).get('name', 'Unknown')
|
||||
tried_sources = task.get('used_sources', set())
|
||||
sources_str = f' (tried {len(tried_sources)} source{"s" if len(tried_sources) != 1 else ""})' if tried_sources else ''
|
||||
print(f"❌ Task failed after 3 retry attempts (0% progress timeout)")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = 'Failed after multiple 0% progress retries'
|
||||
task['error_message'] = f'Download stuck at 0% three times for "{track_label}"{sources_str} — peers may have connection issues'
|
||||
# Clear timers to prevent further retry loops
|
||||
task.pop('queued_start_time', None)
|
||||
task.pop('downloading_start_time', None)
|
||||
|
|
@ -8756,7 +8765,7 @@ def _post_process_matched_download_with_verification(context_key, context, file_
|
|||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = "Simple download file not found after processing."
|
||||
download_tasks[task_id]['error_message'] = f'Downloaded file not found at expected location: {os.path.basename(expected_final_path)}'
|
||||
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
|
|
@ -8853,7 +8862,7 @@ def _post_process_matched_download_with_verification(context_key, context, file_
|
|||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = "File move to transfer folder failed."
|
||||
download_tasks[task_id]['error_message'] = f'File verification failed: expected file at {os.path.basename(expected_final_path)} but it was not found after processing'
|
||||
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
|
|
@ -12778,7 +12787,7 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
if task_age > 600: # 10 minutes
|
||||
print(f"⏰ [Stuck Detection] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Retry timeout after {task_age:.0f} seconds'
|
||||
task['error_message'] = f'Search stuck for {int(task_age // 60)} minutes with no results — timed out'
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
|
|
@ -13156,6 +13165,7 @@ def _run_post_processing_worker(task_id, batch_id):
|
|||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = 'Post-processing failed: missing file or source information from Soulseek transfer'
|
||||
_on_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
|
|
@ -13333,7 +13343,7 @@ def _run_post_processing_worker(task_id, batch_id):
|
|||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = "File not found on disk after download completed."
|
||||
download_tasks[task_id]['error_message'] = f'File not found on disk after 3 search attempts. Expected: {os.path.basename(task_filename)}'
|
||||
_on_download_completed(batch_id, task_id, success=False)
|
||||
return
|
||||
|
||||
|
|
@ -13624,6 +13634,7 @@ def _download_track_worker(task_id, batch_id=None):
|
|||
print(f"🔍 [Modal Worker] About to start search loop for task {task_id} (track: '{track.name}')")
|
||||
|
||||
# 2. Sequential Query Search (matches GUI's start_search_worker_parallel logic)
|
||||
search_diagnostics = [] # Track what happened per query for detailed error messages
|
||||
for query_index, query in enumerate(search_queries):
|
||||
# Cancellation check before each query
|
||||
with tasks_lock:
|
||||
|
|
@ -13656,12 +13667,13 @@ def _download_track_worker(task_id, batch_id=None):
|
|||
return
|
||||
|
||||
if tracks_result:
|
||||
result_count = len(tracks_result)
|
||||
# Validate candidates using GUI's get_valid_candidates logic
|
||||
candidates = get_valid_candidates(tracks_result, track, query)
|
||||
if candidates:
|
||||
print(f"✅ [Modal Worker] Found {len(candidates)} valid candidates for query '{query}'")
|
||||
|
||||
# CRITICAL: Check cancellation before processing candidates
|
||||
|
||||
# CRITICAL: Check cancellation before processing candidates
|
||||
with tasks_lock:
|
||||
if task_id not in download_tasks:
|
||||
print(f"❌ [Modal Worker] Task {task_id} was deleted before processing candidates")
|
||||
|
|
@ -13672,7 +13684,7 @@ def _download_track_worker(task_id, batch_id=None):
|
|||
return
|
||||
# Store candidates for retry fallback (like GUI)
|
||||
download_tasks[task_id]['cached_candidates'] = candidates
|
||||
|
||||
|
||||
# Try to download with these candidates
|
||||
success = _attempt_download_with_candidates(task_id, candidates, track, batch_id)
|
||||
if success:
|
||||
|
|
@ -13686,9 +13698,16 @@ def _download_track_worker(task_id, batch_id=None):
|
|||
if used_filename and used_username:
|
||||
_store_batch_source(batch_id, used_username, used_filename)
|
||||
return # Success, exit the worker
|
||||
|
||||
else:
|
||||
search_diagnostics.append(f'"{query}": {result_count} results, {len(candidates)} passed filters but download failed to start')
|
||||
else:
|
||||
search_diagnostics.append(f'"{query}": {result_count} results but none passed quality/artist filters')
|
||||
else:
|
||||
search_diagnostics.append(f'"{query}": no results on Soulseek')
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Modal Worker] Search failed for query '{query}': {e}")
|
||||
search_diagnostics.append(f'"{query}": search error — {e}')
|
||||
continue
|
||||
|
||||
# If we get here, all search queries failed
|
||||
|
|
@ -13696,6 +13715,8 @@ def _download_track_worker(task_id, batch_id=None):
|
|||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
_diag_summary = ' | '.join(search_diagnostics) if search_diagnostics else 'no queries attempted'
|
||||
download_tasks[task_id]['error_message'] = f'No match found for "{track_name}" by {artist_name or "Unknown"} after {len(search_queries)} queries. Breakdown: {_diag_summary}'
|
||||
|
||||
# Notify batch manager that this task completed (failed) - THREAD SAFE
|
||||
if batch_id:
|
||||
|
|
@ -13717,6 +13738,7 @@ def _download_track_worker(task_id, batch_id=None):
|
|||
try:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f'Unexpected error during download: {type(e).__name__}: {e}'
|
||||
print(f"🔧 [Exception Recovery] Set task {task_id} status to 'failed'")
|
||||
finally:
|
||||
tasks_lock.release()
|
||||
|
|
@ -14338,9 +14360,10 @@ def _build_batch_status_data(batch_id, batch, live_transfers_lookup):
|
|||
|
||||
# If task has been running for more than 10 minutes, force it to fail
|
||||
if task_age > 600 and task['status'] in ['downloading', 'queued', 'searching']:
|
||||
stuck_state = task['status']
|
||||
print(f"⏰ [Safety Valve] Task {task_id} stuck for {task_age:.1f}s - forcing failure")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Task stuck for {task_age:.0f} seconds'
|
||||
task['error_message'] = f'Task stuck in {stuck_state} state for {int(task_age // 60)} minutes — forcibly stopped'
|
||||
|
||||
task_status = {
|
||||
'task_id': task_id,
|
||||
|
|
@ -14353,6 +14376,7 @@ def _build_batch_status_data(batch_id, batch, live_transfers_lookup):
|
|||
'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
|
||||
'error_message': task.get('error_message'), # Surface failure reasons to UI
|
||||
}
|
||||
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
task_filename = task.get('filename') or _ti.get('filename')
|
||||
|
|
@ -14982,7 +15006,7 @@ def _check_batch_completion_v2(batch_id):
|
|||
if task_age > 600: # 10 minutes
|
||||
print(f"⏰ [Stuck Detection V2] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
||||
task['status'] = 'failed'
|
||||
task['error_message'] = f'Retry timeout after {task_age:.0f} seconds'
|
||||
task['error_message'] = f'Search stuck for {int(task_age // 60)} minutes with no results — timed out'
|
||||
finished_count += 1
|
||||
else:
|
||||
retrying_count += 1
|
||||
|
|
|
|||
|
|
@ -7878,6 +7878,52 @@ function stopGlobalDownloadPolling() {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Error tooltip for failed/cancelled downloads (fixed-position, escapes overflow) ---
|
||||
function _getErrorTooltipPopup() {
|
||||
let el = document.getElementById('error-tooltip-popup');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'error-tooltip-popup';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function _hideErrorTooltip() {
|
||||
const popup = document.getElementById('error-tooltip-popup');
|
||||
if (popup) popup.classList.remove('visible');
|
||||
}
|
||||
|
||||
function _ensureErrorTooltipListeners(statusEl) {
|
||||
if (statusEl._errorTooltipBound) return;
|
||||
statusEl._errorTooltipBound = true;
|
||||
statusEl.addEventListener('mouseenter', function () {
|
||||
const msg = this.dataset.errorMsg;
|
||||
if (!msg || !this.offsetParent) return; // skip if element is hidden
|
||||
const popup = _getErrorTooltipPopup();
|
||||
popup.textContent = msg;
|
||||
popup.classList.add('visible');
|
||||
const rect = this.getBoundingClientRect();
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
let left = rect.left + rect.width / 2 - popupRect.width / 2;
|
||||
let top = rect.top - popupRect.height - 10;
|
||||
// Keep within viewport
|
||||
if (left < 8) left = 8;
|
||||
if (left + popupRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - popupRect.width;
|
||||
if (top < 8) { top = rect.bottom + 10; } // flip below if no room above
|
||||
popup.style.left = left + 'px';
|
||||
popup.style.top = top + 'px';
|
||||
});
|
||||
statusEl.addEventListener('mouseleave', _hideErrorTooltip);
|
||||
|
||||
// Dismiss tooltip when the scrollable modal body scrolls
|
||||
const scrollParent = statusEl.closest('.download-missing-modal-body');
|
||||
if (scrollParent && !scrollParent._errorTooltipScrollBound) {
|
||||
scrollParent._errorTooltipScrollBound = true;
|
||||
scrollParent.addEventListener('scroll', _hideErrorTooltip, { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function processModalStatusUpdate(playlistId, data) {
|
||||
// This function contains ALL the existing polling logic from startModalDownloadPolling
|
||||
// Extracted so it can be called from both individual and batched polling
|
||||
|
|
@ -8010,7 +8056,16 @@ function processModalStatusUpdate(playlistId, data) {
|
|||
}
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.classList.remove('has-error-tooltip');
|
||||
statusEl.removeAttribute('title');
|
||||
statusEl.removeAttribute('data-error-msg');
|
||||
statusEl.textContent = statusText;
|
||||
|
||||
if ((task.status === 'failed' || task.status === 'cancelled') && task.error_message) {
|
||||
statusEl.classList.add('has-error-tooltip');
|
||||
statusEl.dataset.errorMsg = task.error_message;
|
||||
_ensureErrorTooltipListeners(statusEl);
|
||||
}
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -22698,3 +22698,49 @@ body {
|
|||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
FAILED DOWNLOAD ERROR TOOLTIP
|
||||
======================================== */
|
||||
|
||||
.has-error-tooltip {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
#error-tooltip-popup {
|
||||
position: fixed;
|
||||
display: none;
|
||||
min-width: 220px;
|
||||
max-width: 480px;
|
||||
padding: 10px 14px;
|
||||
background: linear-gradient(135deg, rgba(30,30,30,0.98), rgba(20,20,20,0.99));
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
border-radius: 10px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
z-index: 100000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
#error-tooltip-popup.visible {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#error-tooltip-popup::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(244, 67, 54, 0.3);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue