From 2852e2b39f0a4a1178a144c7be73285d976c5711 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Thu, 26 Feb 2026 13:09:51 -0800 Subject: [PATCH] Add candidates review modal & API Expose cached search results for failed downloads and add a UI to review them. Implements a new GET /api/downloads/task//candidates endpoint that serializes any cached_candidates and track_info for a task and returns an error message and candidate count. The download worker now collects top raw search results (all_raw_results) and stores them in download_tasks[task_id]['cached_candidates'] when no match is found so users can inspect what Soulseek returned. The batch status payload includes has_candidates to mark "not_found" tasks as reviewable. On the frontend, new script functions (_ensureCandidatesClickListener, showCandidatesModal, _renderCandidatesModal, closeCandidatesModal) fetch and render a modal table of candidates; existing status rendering is updated to attach click handlers and error tooltips. Styles for the modal and a clickable .has-candidates state are added to style.css. --- web_server.py | 53 ++++++++++++++ webui/static/script.js | 124 +++++++++++++++++++++++++++++++- webui/static/style.css | 156 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 1 deletion(-) diff --git a/web_server.py b/web_server.py index 32c9d696..13cc6e6b 100644 --- a/web_server.py +++ b/web_server.py @@ -4948,6 +4948,53 @@ def clear_finished_downloads(): print(f"Error clearing finished downloads: {e}") return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/downloads/task//candidates', methods=['GET']) +def get_task_candidates(task_id): + """Returns the cached search candidates for a download task so the UI can show what was found.""" + try: + with tasks_lock: + task = download_tasks.get(task_id) + if not task: + return jsonify({"error": "Task not found"}), 404 + + candidates = task.get('cached_candidates', []) + track_info = task.get('track_info', {}) + error_message = task.get('error_message', '') + + serialized = [] + for c in candidates: + if hasattr(c, '__dict__'): + serialized.append({ + 'username': getattr(c, 'username', ''), + 'filename': getattr(c, 'filename', ''), + 'size': getattr(c, 'size', 0), + 'bitrate': getattr(c, 'bitrate', None), + 'duration': getattr(c, 'duration', None), + 'quality': getattr(c, 'quality', ''), + 'free_upload_slots': getattr(c, 'free_upload_slots', 0), + 'upload_speed': getattr(c, 'upload_speed', 0), + 'queue_length': getattr(c, 'queue_length', 0), + 'artist': getattr(c, 'artist', None), + 'title': getattr(c, 'title', None), + 'album': getattr(c, 'album', None), + }) + elif isinstance(c, dict): + serialized.append(c) + + return jsonify({ + "task_id": task_id, + "track_info": { + "name": track_info.get('name', 'Unknown') if isinstance(track_info, dict) else 'Unknown', + "artist": track_info.get('artist', 'Unknown') if isinstance(track_info, dict) else 'Unknown', + }, + "error_message": error_message, + "candidates": serialized, + "candidate_count": len(serialized), + }) + except Exception as e: + print(f"❌ [Candidates] Error fetching candidates for task {task_id}: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/quarantine/clear', methods=['POST']) def clear_quarantine(): """Delete all files and folders inside the ss_quarantine directory.""" @@ -15361,6 +15408,7 @@ def _download_track_worker(task_id, batch_id=None): # 2. Sequential Query Search (matches GUI's start_search_worker_parallel logic) search_diagnostics = [] # Track what happened per query for detailed error messages + all_raw_results = [] # Collect raw results across queries for candidate review modal for query_index, query in enumerate(search_queries): # Cancellation check before each query with tasks_lock: @@ -15428,6 +15476,7 @@ def _download_track_worker(task_id, batch_id=None): 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') + all_raw_results.extend(tracks_result[:20]) # Keep top results for review else: search_diagnostics.append(f'"{query}": no results on Soulseek') @@ -15443,6 +15492,9 @@ def _download_track_worker(task_id, batch_id=None): download_tasks[task_id]['status'] = 'not_found' _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}' + # Store raw results so the user can review what Soulseek returned + if all_raw_results and not download_tasks[task_id].get('cached_candidates'): + download_tasks[task_id]['cached_candidates'] = all_raw_results # Notify batch manager that this task completed (failed) - THREAD SAFE if batch_id: @@ -16127,6 +16179,7 @@ def _build_batch_status_data(batch_id, batch, live_transfers_lookup): '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 + 'has_candidates': bool(task.get('cached_candidates')), # Whether search found results (for clickable review) } _ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {} task_filename = task.get('filename') or _ti.get('filename') diff --git a/webui/static/script.js b/webui/static/script.js index daf8bfef..ecfce9b7 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -8417,6 +8417,110 @@ function _ensureErrorTooltipListeners(statusEl) { } } +function _ensureCandidatesClickListener(statusEl) { + if (statusEl._candidatesClickBound) return; + statusEl._candidatesClickBound = true; + statusEl.addEventListener('click', function (e) { + e.stopPropagation(); + _hideErrorTooltip(); + const taskId = this.dataset.taskId; + if (taskId) showCandidatesModal(taskId); + }); +} + +async function showCandidatesModal(taskId) { + try { + const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/candidates`); + if (!resp.ok) { console.error('Failed to fetch candidates:', resp.status); return; } + const data = await resp.json(); + _renderCandidatesModal(data); + } catch (err) { + console.error('Error fetching candidates:', err); + } +} + +function _renderCandidatesModal(data) { + let overlay = document.getElementById('candidates-modal-overlay'); + if (overlay) overlay.remove(); + + const trackName = data.track_info?.name || 'Unknown Track'; + const trackArtist = data.track_info?.artist || 'Unknown Artist'; + const candidates = data.candidates || []; + const errorMsg = data.error_message || ''; + + const fmtSize = (bytes) => { + if (!bytes) return '-'; + const units = ['B', 'KB', 'MB', 'GB']; + let s = bytes, u = 0; + while (s >= 1024 && u < units.length - 1) { s /= 1024; u++; } + return `${s.toFixed(1)} ${units[u]}`; + }; + const fmtDur = (ms) => { + if (!ms) return '-'; + const sec = Math.floor(ms / 1000); + return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`; + }; + + let tableRows = ''; + if (candidates.length === 0) { + tableRows = ` + No candidates were found during search.`; + } else { + candidates.forEach((c, i) => { + const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-'; + const qBadge = c.quality + ? `${c.quality.toUpperCase()}` + : ''; + tableRows += ` + ${i + 1} + ${escapeHtml(shortFile)} + ${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''} + ${fmtSize(c.size)} + ${fmtDur(c.duration)} + ${escapeHtml(c.username || '-')} + `; + }); + } + + overlay = document.createElement('div'); + overlay.id = 'candidates-modal-overlay'; + overlay.className = 'candidates-modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) closeCandidatesModal(); }; + overlay.innerHTML = ` +
+
+
+

Search Results

+
${escapeHtml(trackName)} — ${escapeHtml(trackArtist)}
+
+ +
+
+ ${errorMsg ? `
${escapeHtml(errorMsg)}
` : ''} +
${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}
+
+ + + + + ${tableRows} +
#FileQualitySizeDurationUser
+
+
+
`; + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); +} + +function closeCandidatesModal() { + const overlay = document.getElementById('candidates-modal-overlay'); + if (overlay) { + overlay.classList.remove('visible'); + setTimeout(() => overlay.remove(), 300); + } +} + 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 @@ -8561,6 +8665,12 @@ function processModalStatusUpdate(playlistId, data) { statusEl.dataset.errorMsg = task.error_message; _ensureErrorTooltipListeners(statusEl); } + // Make not_found cells clickable to review search candidates + if (task.status === 'not_found' && task.has_candidates) { + statusEl.classList.add('has-candidates'); + statusEl.dataset.taskId = task.task_id; + _ensureCandidatesClickListener(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}`); @@ -15086,7 +15196,19 @@ function updateCompletedModalResults(playlistId, downloadData) { default: statusText = `⚪ ${task.status}`; break; } - if (statusEl) statusEl.textContent = statusText; + if (statusEl) { + statusEl.textContent = statusText; + if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) { + statusEl.classList.add('has-error-tooltip'); + statusEl.dataset.errorMsg = task.error_message; + _ensureErrorTooltipListeners(statusEl); + } + if (task.status === 'not_found' && task.has_candidates) { + statusEl.classList.add('has-candidates'); + statusEl.dataset.taskId = task.task_id; + _ensureCandidatesClickListener(statusEl); + } + } if (actionsEl) actionsEl.innerHTML = '-'; // Remove action buttons for completed tasks }); diff --git a/webui/static/style.css b/webui/static/style.css index b7361a04..e6b33319 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -25288,6 +25288,162 @@ body { border-top-color: rgba(244, 67, 54, 0.3); } +/* ===================================== + SEARCH CANDIDATES MODAL + ===================================== */ + +.has-candidates { + cursor: pointer !important; + text-decoration: underline dotted !important; +} +.has-candidates:hover { + color: #1db954 !important; +} + +.candidates-modal-overlay { + position: fixed; + top: 0; left: 0; + width: 100vw; height: 100vh; + background: rgba(18, 18, 18, 0.85); + backdrop-filter: blur(12px); + z-index: 100001; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} +.candidates-modal-overlay.visible { + opacity: 1; + pointer-events: auto; +} + +.candidates-modal { + background: linear-gradient(135deg, #1a1a1a 0%, #121212 100%); + border-radius: 16px; + border: 1px solid rgba(29, 185, 84, 0.2); + width: 900px; + max-width: 95vw; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 25px 80px rgba(0, 0, 0, 0.7); + transform: scale(0.95); + transition: transform 0.3s ease; +} +.candidates-modal-overlay.visible .candidates-modal { + transform: scale(1); +} + +.candidates-modal-header { + padding: 20px 28px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + justify-content: space-between; + align-items: center; +} +.candidates-modal-title { + color: #fff; + font-size: 20px; + font-weight: 700; + margin: 0; +} +.candidates-modal-subtitle { + color: rgba(255, 255, 255, 0.6); + font-size: 14px; + margin-top: 4px; +} +.candidates-modal-close { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); + border: none; + border-radius: 50%; + width: 32px; height: 32px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} +.candidates-modal-close:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.candidates-modal-body { + padding: 20px 28px; + overflow-y: auto; + flex: 1; +} +.candidates-modal-body::-webkit-scrollbar { width: 8px; } +.candidates-modal-body::-webkit-scrollbar-track { background: rgba(255,255,255,0.05); border-radius: 4px; } +.candidates-modal-body::-webkit-scrollbar-thumb { background: rgba(29,185,84,0.3); border-radius: 4px; } + +.candidates-error-summary { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 12px 16px; + color: rgba(255, 255, 255, 0.7); + font-size: 13px; + margin-bottom: 16px; + line-height: 1.5; + word-break: break-word; +} +.candidates-count { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + margin-bottom: 12px; +} + +.candidates-table-wrapper { overflow-x: auto; } +.candidates-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.candidates-table thead th { + color: rgba(255, 255, 255, 0.5); + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.5px; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + text-align: left; + white-space: nowrap; +} +.candidates-table tbody tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + transition: background 0.15s ease; +} +.candidates-table tbody tr:hover { + background: rgba(29, 185, 84, 0.05); +} +.candidates-table td { + padding: 10px 12px; + color: rgba(255, 255, 255, 0.85); + vertical-align: middle; +} +.candidates-col-index { color: rgba(255,255,255,0.3); width: 30px; text-align: center; } +.candidates-col-file { max-width: 350px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.candidates-col-quality, .candidates-col-size, .candidates-col-duration, .candidates-col-user { white-space: nowrap; } + +.candidates-quality-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + margin-right: 4px; +} +.candidates-quality-flac { background: rgba(29,185,84,0.2); color: #1db954; } +.candidates-quality-mp3 { background: rgba(100,149,237,0.2); color: #6495ed; } +.candidates-quality-ogg, .candidates-quality-aac, .candidates-quality-wma { background: rgba(255,165,0,0.2); color: #ffa500; } + /* ===================================== HYDRABASE PAGE ===================================== */