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/<id>/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.
pull/165/head
Broque Thomas 3 months ago
parent 2852e2b39f
commit e3a5608c95

@ -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/<task_id>/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."""

@ -8463,7 +8463,7 @@ function _renderCandidatesModal(data) {
let tableRows = '';
if (candidates.length === 0) {
tableRows = `<tr><td colspan="6" style="text-align:center; color: rgba(255,255,255,0.5); padding: 30px;">
tableRows = `<tr><td colspan="7" 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) => {
@ -8478,6 +8478,7 @@ function _renderCandidatesModal(data) {
<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>
<td class="candidates-col-action"><button class="candidates-download-btn" data-index="${i}" title="Download this file"></button></td>
</tr>`;
});
}
@ -8501,7 +8502,7 @@ function _renderCandidatesModal(data) {
<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>
<th>#</th><th>File</th><th>Quality</th><th>Size</th><th>Duration</th><th>User</th><th></th>
</tr></thead>
<tbody>${tableRows}</tbody>
</table>
@ -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() {

@ -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

Loading…
Cancel
Save