From e3a5608c952212edcca97f64dcbb7fd78a0573e4 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Thu, 26 Feb 2026 13:45:38 -0800 Subject: [PATCH] Add manual candidate download API and UI Add server endpoint to trigger a manual download for a user-selected candidate from the candidates modal (/api/downloads/task//download-candidate). The endpoint validates input, resets task and batch state (status, error, used_sources, active_count, permanently_failed_tracks), reconstructs Track/TrackResult objects and dispatches a background download attempt via missing_download_executor. Update the frontend candidates modal to show a download button per candidate, wire it to POST the candidate to the new API, and add CSS for table layout and download button styling. Enables restarting failed/not_found tasks by choosing a specific source without blocking the UI. --- web_server.py | 111 +++++++++++++++++++++++++++++++++++++++++ webui/static/script.js | 35 ++++++++++++- webui/static/style.css | 28 +++++++++-- 3 files changed, 169 insertions(+), 5 deletions(-) diff --git a/web_server.py b/web_server.py index 13cc6e6b..40535599 100644 --- a/web_server.py +++ b/web_server.py @@ -4995,6 +4995,117 @@ def get_task_candidates(task_id): print(f"❌ [Candidates] Error fetching candidates for task {task_id}: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/downloads/task//download-candidate', methods=['POST']) +def download_selected_candidate(task_id): + """Restart a not_found/failed task by downloading a user-selected candidate.""" + try: + data = request.get_json() + if not data or not data.get('username') or not data.get('filename'): + return jsonify({"error": "Missing username or filename"}), 400 + + username = data['username'] + filename = data['filename'] + size = data.get('size', 0) + + with tasks_lock: + task = download_tasks.get(task_id) + if not task: + return jsonify({"error": "Task not found"}), 404 + if task['status'] not in ('not_found', 'failed'): + return jsonify({"error": f"Task is {task['status']}, not eligible for retry"}), 400 + + batch_id = task.get('batch_id') + track_info = task.get('track_info', {}) + + # Reset task state + task['status'] = 'downloading' + task['error_message'] = None + task['status_change_time'] = time.time() + task.pop('download_id', None) + task.pop('username', None) + task.pop('filename', None) + # Clear the selected candidate from used_sources so it won't be skipped + used_sources = task.get('used_sources', set()) + source_key = f"{username}_{os.path.basename(filename)}" + used_sources.discard(source_key) + + # Reset batch tracking for this task + if batch_id and batch_id in download_batches: + batch = download_batches[batch_id] + # Remove from completed set so _on_download_completed can fire again + completed_set = batch.get('_completed_task_ids', set()) + completed_set.discard(task_id) + # Remove from permanently_failed_tracks + track_index = task.get('track_index') + batch['permanently_failed_tracks'] = [ + t for t in batch.get('permanently_failed_tracks', []) + if t.get('table_index') != track_index and t.get('download_index') != track_index + ] + # Restore worker slot + batch['active_count'] = batch.get('active_count', 0) + 1 + + # Build a TrackResult-like candidate object + from core.soulseek_client import TrackResult + candidate = TrackResult( + username=username, + filename=filename, + size=size, + bitrate=data.get('bitrate'), + duration=data.get('duration'), + quality=data.get('quality', 'unknown'), + free_upload_slots=data.get('free_upload_slots', 0), + upload_speed=data.get('upload_speed', 0), + queue_length=data.get('queue_length', 0), + artist=data.get('artist'), + title=data.get('title'), + album=data.get('album'), + ) + candidate.confidence = 1.0 # Required by _attempt_download_with_candidates sort + + # Reconstruct Track object from task's track_info + from core.itunes_client import Track + artists = track_info.get('artists', []) + artist_names = [] + for a in (artists if isinstance(artists, list) else []): + if isinstance(a, dict): + artist_names.append(a.get('name', 'Unknown')) + elif isinstance(a, str): + artist_names.append(a) + if not artist_names: + artist_names = [track_info.get('artist', 'Unknown')] + + track = Track( + id=track_info.get('id', ''), + name=track_info.get('name', 'Unknown'), + artists=artist_names, + album=track_info.get('album', {}).get('name', '') if isinstance(track_info.get('album'), dict) else track_info.get('album', ''), + duration_ms=track_info.get('duration_ms', 0), + popularity=0, + ) + + # Submit to thread pool — don't block the request + def _run_manual_download(): + success = _attempt_download_with_candidates(task_id, [candidate], track, batch_id) + if not success: + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'failed' + download_tasks[task_id]['error_message'] = 'Manual download failed to start — user may be offline' + if batch_id: + _on_download_completed(batch_id, task_id, success=False) + + missing_download_executor.submit(_run_manual_download) + + track_name = track_info.get('name', 'Unknown') + print(f"🎯 [Manual Download] User selected candidate for '{track_name}' from {username}") + return jsonify({"success": True, "message": f"Download initiated for '{track_name}'"}) + + except Exception as e: + print(f"❌ [Manual Download] Error: {e}") + import traceback + traceback.print_exc() + 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.""" diff --git a/webui/static/script.js b/webui/static/script.js index ecfce9b7..a3367176 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -8463,7 +8463,7 @@ function _renderCandidatesModal(data) { let tableRows = ''; if (candidates.length === 0) { - tableRows = ` + tableRows = ` No candidates were found during search.`; } else { candidates.forEach((c, i) => { @@ -8478,6 +8478,7 @@ function _renderCandidatesModal(data) { ${fmtSize(c.size)} ${fmtDur(c.duration)} ${escapeHtml(c.username || '-')} + `; }); } @@ -8501,7 +8502,7 @@ function _renderCandidatesModal(data) {
- + ${tableRows}
#FileQualitySizeDurationUser#FileQualitySizeDurationUser
@@ -8511,6 +8512,36 @@ function _renderCandidatesModal(data) { document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('visible')); + + // Bind download buttons + overlay.querySelectorAll('.candidates-download-btn').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.index); + const c = candidates[idx]; + if (c) downloadCandidate(data.task_id, c, trackName); + }); + }); +} + +async function downloadCandidate(taskId, candidate, trackName) { + if (!confirm(`Download this file as "${trackName}"?\n\n${candidate.filename?.split(/[/\\]/).pop() || 'Unknown file'}\nfrom ${candidate.username || 'Unknown user'}`)) return; + try { + const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/download-candidate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(candidate) + }); + const result = await resp.json(); + if (result.success) { + closeCandidatesModal(); + showToast(result.message || 'Download initiated', 'success'); + } else { + showToast(`Failed: ${result.error}`, 'error'); + } + } catch (err) { + console.error('Error initiating manual download:', err); + showToast('Failed to initiate download', 'error'); + } } function closeCandidatesModal() { diff --git a/webui/static/style.css b/webui/static/style.css index e6b33319..d752245d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -25403,6 +25403,7 @@ body { width: 100%; border-collapse: collapse; font-size: 13px; + table-layout: fixed; } .candidates-table thead th { color: rgba(255, 255, 255, 0.5); @@ -25427,9 +25428,13 @@ body { 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-col-index { color: rgba(255,255,255,0.3); width: 32px; text-align: center; } +.candidates-col-file { width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.candidates-col-quality { width: 100px; white-space: nowrap; } +.candidates-col-size { width: 70px; white-space: nowrap; } +.candidates-col-duration { width: 60px; white-space: nowrap; } +.candidates-col-user { width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.candidates-col-action { width: 44px; text-align: center; padding: 6px 4px !important; } .candidates-quality-badge { display: inline-block; @@ -25443,6 +25448,23 @@ body { .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; } +.candidates-download-btn { + background: rgba(29, 185, 84, 0.15); + border: 1px solid rgba(29, 185, 84, 0.3); + color: #1db954; + border-radius: 6px; + width: 30px; height: 30px; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; +} +.candidates-download-btn:hover { + background: rgba(29, 185, 84, 0.3); + border-color: #1db954; +} /* ===================================== HYDRABASE PAGE