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:
parent
b814ae17ce
commit
2852e2b39f
3 changed files with 332 additions and 1 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()">✕</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
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
===================================== */
|
||||
|
|
|
|||
Loading…
Reference in a new issue