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.
This commit is contained in:
parent
2852e2b39f
commit
e3a5608c95
3 changed files with 169 additions and 5 deletions
111
web_server.py
111
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/<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…
Reference in a new issue