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/<task_id>/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.
This commit is contained in:
Broque Thomas 2026-02-26 13:09:51 -08:00
parent b814ae17ce
commit 2852e2b39f
3 changed files with 332 additions and 1 deletions

View file

@ -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/<task_id>/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')

View file

@ -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 = `<tr><td colspan="6" style="text-align:center; color: rgba(255,255,255,0.5); padding: 30px;">
No candidates were found during search.</td></tr>`;
} else {
candidates.forEach((c, i) => {
const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-';
const qBadge = c.quality
? `<span class="candidates-quality-badge candidates-quality-${c.quality.toLowerCase()}">${c.quality.toUpperCase()}</span>`
: '';
tableRows += `<tr>
<td class="candidates-col-index">${i + 1}</td>
<td class="candidates-col-file" title="${escapeHtml(c.filename || '')}">${escapeHtml(shortFile)}</td>
<td class="candidates-col-quality">${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''}</td>
<td class="candidates-col-size">${fmtSize(c.size)}</td>
<td class="candidates-col-duration">${fmtDur(c.duration)}</td>
<td class="candidates-col-user" title="Queue: ${c.queue_length || 0} | Slots: ${c.free_upload_slots || 0}">${escapeHtml(c.username || '-')}</td>
</tr>`;
});
}
overlay = document.createElement('div');
overlay.id = 'candidates-modal-overlay';
overlay.className = 'candidates-modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) closeCandidatesModal(); };
overlay.innerHTML = `
<div class="candidates-modal">
<div class="candidates-modal-header">
<div>
<h2 class="candidates-modal-title">Search Results</h2>
<div class="candidates-modal-subtitle">${escapeHtml(trackName)} ${escapeHtml(trackArtist)}</div>
</div>
<button class="candidates-modal-close" onclick="closeCandidatesModal()">&#x2715;</button>
</div>
<div class="candidates-modal-body">
${errorMsg ? `<div class="candidates-error-summary">${escapeHtml(errorMsg)}</div>` : ''}
<div class="candidates-count">${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}</div>
<div class="candidates-table-wrapper">
<table class="candidates-table">
<thead><tr>
<th>#</th><th>File</th><th>Quality</th><th>Size</th><th>Duration</th><th>User</th>
</tr></thead>
<tbody>${tableRows}</tbody>
</table>
</div>
</div>
</div>`;
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
});

View file

@ -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
===================================== */