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
|
return False
|
||||||
else:
|
else:
|
||||||
# Too many error retries, mark as failed
|
# 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")
|
print(f"❌ Task failed after 3 error retry attempts")
|
||||||
task['status'] = 'failed'
|
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
|
# CRITICAL: Notify batch manager so track is added to permanently_failed_tracks
|
||||||
batch_id = task.get('batch_id')
|
batch_id = task.get('batch_id')
|
||||||
|
|
@ -836,9 +839,12 @@ class WebUIDownloadMonitor:
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
# Too many retries, mark as failed
|
# 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)")
|
print(f"❌ Task failed after 3 retry attempts (queue timeout)")
|
||||||
task['status'] = 'failed'
|
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
|
# Clear timers to prevent further retry loops
|
||||||
task.pop('queued_start_time', None)
|
task.pop('queued_start_time', None)
|
||||||
task.pop('downloading_start_time', None)
|
task.pop('downloading_start_time', None)
|
||||||
|
|
@ -933,9 +939,12 @@ class WebUIDownloadMonitor:
|
||||||
# Wait longer before next retry
|
# Wait longer before next retry
|
||||||
return False
|
return False
|
||||||
else:
|
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)")
|
print(f"❌ Task failed after 3 retry attempts (0% progress timeout)")
|
||||||
task['status'] = 'failed'
|
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
|
# Clear timers to prevent further retry loops
|
||||||
task.pop('queued_start_time', None)
|
task.pop('queued_start_time', None)
|
||||||
task.pop('downloading_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:
|
with tasks_lock:
|
||||||
if task_id in download_tasks:
|
if task_id in download_tasks:
|
||||||
download_tasks[task_id]['status'] = 'failed'
|
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:
|
with matched_context_lock:
|
||||||
if context_key in matched_downloads_context:
|
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:
|
with tasks_lock:
|
||||||
if task_id in download_tasks:
|
if task_id in download_tasks:
|
||||||
download_tasks[task_id]['status'] = 'failed'
|
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:
|
with matched_context_lock:
|
||||||
if context_key in matched_downloads_context:
|
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
|
if task_age > 600: # 10 minutes
|
||||||
print(f"⏰ [Stuck Detection] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
print(f"⏰ [Stuck Detection] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
||||||
task['status'] = 'failed'
|
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
|
finished_count += 1
|
||||||
else:
|
else:
|
||||||
retrying_count += 1
|
retrying_count += 1
|
||||||
|
|
@ -13156,6 +13165,7 @@ def _run_post_processing_worker(task_id, batch_id):
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
if task_id in download_tasks:
|
if task_id in download_tasks:
|
||||||
download_tasks[task_id]['status'] = 'failed'
|
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)
|
_on_download_completed(batch_id, task_id, success=False)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -13333,7 +13343,7 @@ def _run_post_processing_worker(task_id, batch_id):
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
if task_id in download_tasks:
|
if task_id in download_tasks:
|
||||||
download_tasks[task_id]['status'] = 'failed'
|
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)
|
_on_download_completed(batch_id, task_id, success=False)
|
||||||
return
|
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}')")
|
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)
|
# 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):
|
for query_index, query in enumerate(search_queries):
|
||||||
# Cancellation check before each query
|
# Cancellation check before each query
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
|
|
@ -13656,6 +13667,7 @@ def _download_track_worker(task_id, batch_id=None):
|
||||||
return
|
return
|
||||||
|
|
||||||
if tracks_result:
|
if tracks_result:
|
||||||
|
result_count = len(tracks_result)
|
||||||
# Validate candidates using GUI's get_valid_candidates logic
|
# Validate candidates using GUI's get_valid_candidates logic
|
||||||
candidates = get_valid_candidates(tracks_result, track, query)
|
candidates = get_valid_candidates(tracks_result, track, query)
|
||||||
if candidates:
|
if candidates:
|
||||||
|
|
@ -13686,9 +13698,16 @@ def _download_track_worker(task_id, batch_id=None):
|
||||||
if used_filename and used_username:
|
if used_filename and used_username:
|
||||||
_store_batch_source(batch_id, used_username, used_filename)
|
_store_batch_source(batch_id, used_username, used_filename)
|
||||||
return # Success, exit the worker
|
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:
|
except Exception as e:
|
||||||
print(f"⚠️ [Modal Worker] Search failed for query '{query}': {e}")
|
print(f"⚠️ [Modal Worker] Search failed for query '{query}': {e}")
|
||||||
|
search_diagnostics.append(f'"{query}": search error — {e}')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If we get here, all search queries failed
|
# If we get here, all search queries failed
|
||||||
|
|
@ -13696,6 +13715,8 @@ def _download_track_worker(task_id, batch_id=None):
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
if task_id in download_tasks:
|
if task_id in download_tasks:
|
||||||
download_tasks[task_id]['status'] = 'failed'
|
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
|
# Notify batch manager that this task completed (failed) - THREAD SAFE
|
||||||
if batch_id:
|
if batch_id:
|
||||||
|
|
@ -13717,6 +13738,7 @@ def _download_track_worker(task_id, batch_id=None):
|
||||||
try:
|
try:
|
||||||
if task_id in download_tasks:
|
if task_id in download_tasks:
|
||||||
download_tasks[task_id]['status'] = 'failed'
|
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'")
|
print(f"🔧 [Exception Recovery] Set task {task_id} status to 'failed'")
|
||||||
finally:
|
finally:
|
||||||
tasks_lock.release()
|
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 has been running for more than 10 minutes, force it to fail
|
||||||
if task_age > 600 and task['status'] in ['downloading', 'queued', 'searching']:
|
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")
|
print(f"⏰ [Safety Valve] Task {task_id} stuck for {task_age:.1f}s - forcing failure")
|
||||||
task['status'] = 'failed'
|
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_status = {
|
||||||
'task_id': task_id,
|
'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'),
|
'cancel_timestamp': task.get('cancel_timestamp'),
|
||||||
'ui_state': task.get('ui_state', 'normal'), # normal|cancelling|cancelled
|
'ui_state': task.get('ui_state', 'normal'), # normal|cancelling|cancelled
|
||||||
'playlist_id': task.get('playlist_id'), # For V2 system identification
|
'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 {}
|
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||||
task_filename = task.get('filename') or _ti.get('filename')
|
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
|
if task_age > 600: # 10 minutes
|
||||||
print(f"⏰ [Stuck Detection V2] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
print(f"⏰ [Stuck Detection V2] Task {task_id} stuck in searching for {task_age:.0f}s - forcing failure")
|
||||||
task['status'] = 'failed'
|
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
|
finished_count += 1
|
||||||
else:
|
else:
|
||||||
retrying_count += 1
|
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) {
|
function processModalStatusUpdate(playlistId, data) {
|
||||||
// This function contains ALL the existing polling logic from startModalDownloadPolling
|
// This function contains ALL the existing polling logic from startModalDownloadPolling
|
||||||
// Extracted so it can be called from both individual and batched polling
|
// Extracted so it can be called from both individual and batched polling
|
||||||
|
|
@ -8010,7 +8056,16 @@ function processModalStatusUpdate(playlistId, data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
|
statusEl.classList.remove('has-error-tooltip');
|
||||||
|
statusEl.removeAttribute('title');
|
||||||
|
statusEl.removeAttribute('data-error-msg');
|
||||||
statusEl.textContent = statusText;
|
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)' : ''}`);
|
console.debug(`✅ [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`❌ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`);
|
console.warn(`❌ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`);
|
||||||
|
|
|
||||||
|
|
@ -22698,3 +22698,49 @@ body {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 24px;
|
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