Stream download source results progressively via NDJSON
Step 2 of the redownload modal now streams results as each download source responds instead of waiting for all sources to finish. Tidal/ YouTube/Qobuz columns appear instantly while Soulseek searches. Backend: search-sources endpoint uses ThreadPoolExecutor + NDJSON streaming — one JSON line per source as it completes. Frontend: reads the NDJSON stream, appends columns with fade-in animation as each source responds. Download button enables as soon as any results arrive. Each source gets its own column with results grouped and sorted by confidence. Visual confidence bars, format badges, and source-specific metadata (Soulseek username/slots). Best overall match auto-selected.
This commit is contained in:
parent
40f3621c48
commit
0fd6be3239
3 changed files with 435 additions and 126 deletions
161
web_server.py
161
web_server.py
|
|
@ -13068,13 +13068,20 @@ def redownload_search_metadata(track_id):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Deezer client not available for redownload search: {e}")
|
logger.debug(f"Deezer client not available for redownload search: {e}")
|
||||||
|
|
||||||
|
# Build source-optimized queries
|
||||||
|
deezer_query = f'artist:"{artist_name}" track:"{clean_title}"'
|
||||||
|
|
||||||
def _search_source(source_name, client):
|
def _search_source(source_name, client):
|
||||||
try:
|
try:
|
||||||
logger.info(f"[Redownload] Searching {source_name} for: {query}")
|
# Deezer works best with structured artist:/track: queries
|
||||||
track_objs = client.search_tracks(query, limit=10)
|
search_q = deezer_query if source_name == 'deezer' else query
|
||||||
# If no results with full query, try title only
|
logger.info(f"[Redownload] Searching {source_name} for: {search_q}")
|
||||||
if not track_objs and clean_title:
|
track_objs = client.search_tracks(search_q, limit=10)
|
||||||
logger.info(f"[Redownload] {source_name} got 0 results, trying title only: {clean_title}")
|
# If no results, try plain query as fallback
|
||||||
|
if not track_objs and search_q != query:
|
||||||
|
track_objs = client.search_tracks(query, limit=10)
|
||||||
|
# Last resort: title only
|
||||||
|
if not track_objs and clean_title != query:
|
||||||
track_objs = client.search_tracks(clean_title, limit=10)
|
track_objs = client.search_tracks(clean_title, limit=10)
|
||||||
logger.info(f"[Redownload] {source_name} returned {len(track_objs)} results")
|
logger.info(f"[Redownload] {source_name} returned {len(track_objs)} results")
|
||||||
results = []
|
results = []
|
||||||
|
|
@ -13151,69 +13158,109 @@ def redownload_search_sources(track_id):
|
||||||
if not search_queries:
|
if not search_queries:
|
||||||
search_queries = [f"{metadata.get('artist', '')} {metadata['name']}".strip()]
|
search_queries = [f"{metadata.get('artist', '')} {metadata['name']}".strip()]
|
||||||
|
|
||||||
# Limit to first 3 queries for speed
|
# Use first 2 queries for speed
|
||||||
search_queries = search_queries[:3]
|
search_queries = search_queries[:2]
|
||||||
|
|
||||||
# Search download sources
|
# Search ALL configured download sources individually (not through hybrid which stops at first hit)
|
||||||
candidates = []
|
candidates = []
|
||||||
database = get_database()
|
database = get_database()
|
||||||
|
|
||||||
for qi, query in enumerate(search_queries):
|
# Get all available download source clients
|
||||||
try:
|
download_clients = {}
|
||||||
tracks_result, _ = run_async(soulseek_client.search(query, timeout=25))
|
try:
|
||||||
if not tracks_result:
|
orch = soulseek_client # The download orchestrator
|
||||||
continue
|
if hasattr(orch, 'soulseek') and orch.soulseek:
|
||||||
|
if not (hasattr(orch.soulseek, 'is_configured') and not orch.soulseek.is_configured()):
|
||||||
|
download_clients['soulseek'] = orch.soulseek
|
||||||
|
if hasattr(orch, 'youtube') and orch.youtube:
|
||||||
|
if not (hasattr(orch.youtube, 'is_configured') and not orch.youtube.is_configured()):
|
||||||
|
download_clients['youtube'] = orch.youtube
|
||||||
|
if hasattr(orch, 'tidal') and orch.tidal:
|
||||||
|
if not (hasattr(orch.tidal, 'is_configured') and not orch.tidal.is_configured()):
|
||||||
|
download_clients['tidal'] = orch.tidal
|
||||||
|
if hasattr(orch, 'qobuz') and orch.qobuz:
|
||||||
|
if not (hasattr(orch.qobuz, 'is_configured') and not orch.qobuz.is_configured()):
|
||||||
|
download_clients['qobuz'] = orch.qobuz
|
||||||
|
if hasattr(orch, 'hifi') and orch.hifi:
|
||||||
|
if not (hasattr(orch.hifi, 'is_configured') and not orch.hifi.is_configured()):
|
||||||
|
download_clients['hifi'] = orch.hifi
|
||||||
|
if hasattr(orch, 'deezer_dl') and orch.deezer_dl:
|
||||||
|
if not (hasattr(orch.deezer_dl, 'is_configured') and not orch.deezer_dl.is_configured()):
|
||||||
|
download_clients['deezer_dl'] = orch.deezer_dl
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Redownload] Error getting download clients: {e}")
|
||||||
|
|
||||||
valid = get_valid_candidates(tracks_result, track_obj, query)
|
if not download_clients:
|
||||||
for candidate in valid:
|
# Fallback: use orchestrator directly
|
||||||
# Check blacklist
|
download_clients = {'default': soulseek_client}
|
||||||
is_bl = database.is_blacklisted(candidate.username, candidate.filename)
|
|
||||||
|
|
||||||
# Extract display name from filename
|
logger.info(f"[Redownload] Streaming search across {len(download_clients)} sources: {list(download_clients.keys())}")
|
||||||
display_name = os.path.basename(candidate.filename.replace('\\', '/'))
|
|
||||||
ext = os.path.splitext(display_name)[1].lstrip('.').upper()
|
|
||||||
quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or ''
|
|
||||||
|
|
||||||
candidates.append({
|
def _search_one_source(source_name, client):
|
||||||
'username': candidate.username,
|
"""Search a single download source and return formatted candidates."""
|
||||||
'filename': candidate.filename,
|
source_candidates = []
|
||||||
'display_name': display_name,
|
for qi, q in enumerate(search_queries):
|
||||||
'size': candidate.size or 0,
|
try:
|
||||||
'size_display': f"{(candidate.size or 0) / 1048576:.1f} MB",
|
tracks_result, _ = run_async(client.search(q, timeout=20))
|
||||||
'bitrate': candidate.bitrate or 0,
|
if not tracks_result:
|
||||||
'quality': quality,
|
continue
|
||||||
'duration': candidate.duration or 0,
|
valid = get_valid_candidates(tracks_result, track_obj, q)
|
||||||
'confidence': round(getattr(candidate, 'confidence', 0), 3),
|
for candidate in valid:
|
||||||
'source_query': query,
|
is_bl = database.is_blacklisted(candidate.username, candidate.filename)
|
||||||
'blacklisted': is_bl,
|
display_name = os.path.basename(candidate.filename.replace('\\', '/'))
|
||||||
'free_upload_slots': getattr(candidate, 'free_upload_slots', 0),
|
ext = os.path.splitext(display_name)[1].lstrip('.').upper()
|
||||||
'upload_speed': getattr(candidate, 'upload_speed', 0),
|
quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or ''
|
||||||
'queue_length': getattr(candidate, 'queue_length', 0),
|
svc = source_name if source_name != 'default' else 'hybrid'
|
||||||
})
|
uname = candidate.username
|
||||||
except Exception as e:
|
if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'):
|
||||||
logger.debug(f"Download source search failed for query '{query}': {e}")
|
svc = uname
|
||||||
|
source_candidates.append({
|
||||||
|
'username': uname,
|
||||||
|
'filename': candidate.filename,
|
||||||
|
'display_name': display_name,
|
||||||
|
'size': candidate.size or 0,
|
||||||
|
'size_display': f"{(candidate.size or 0) / 1048576:.1f} MB",
|
||||||
|
'bitrate': candidate.bitrate or 0,
|
||||||
|
'quality': quality,
|
||||||
|
'duration': candidate.duration or 0,
|
||||||
|
'confidence': round(getattr(candidate, 'confidence', 0), 3),
|
||||||
|
'source_service': svc,
|
||||||
|
'source_query': q,
|
||||||
|
'blacklisted': is_bl,
|
||||||
|
'free_upload_slots': getattr(candidate, 'free_upload_slots', 0),
|
||||||
|
'upload_speed': getattr(candidate, 'upload_speed', 0),
|
||||||
|
'queue_length': getattr(candidate, 'queue_length', 0),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Redownload] {source_name} search failed for query '{q}': {e}")
|
||||||
|
# Deduplicate within source
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for c in source_candidates:
|
||||||
|
key = f"{c['username']}|{c['filename']}"
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
unique.append(c)
|
||||||
|
unique.sort(key=lambda c: (-int(not c['blacklisted']), -c['confidence']))
|
||||||
|
return unique
|
||||||
|
|
||||||
# Deduplicate by username+filename
|
# Stream NDJSON — one line per source as it completes
|
||||||
seen = set()
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
unique = []
|
|
||||||
for c in candidates:
|
|
||||||
key = f"{c['username']}|{c['filename']}"
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
unique.append(c)
|
|
||||||
candidates = unique
|
|
||||||
|
|
||||||
# Sort: non-blacklisted first, then by confidence
|
def generate_stream():
|
||||||
candidates.sort(key=lambda c: (-int(not c['blacklisted']), -c['confidence']))
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
futures = {pool.submit(_search_one_source, name, client): name for name, client in download_clients.items()}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
source_name = futures[future]
|
||||||
|
try:
|
||||||
|
results = future.result()
|
||||||
|
yield json.dumps({'source': source_name, 'candidates': results}) + '\n'
|
||||||
|
except Exception as e:
|
||||||
|
yield json.dumps({'source': source_name, 'candidates': [], 'error': str(e)}) + '\n'
|
||||||
|
yield json.dumps({'done': True}) + '\n'
|
||||||
|
|
||||||
best_idx = next((i for i, c in enumerate(candidates) if not c['blacklisted']), 0) if candidates else -1
|
return app.response_class(generate_stream(), mimetype='application/x-ndjson', headers={'X-Accel-Buffering': 'no'})
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"success": True,
|
|
||||||
"candidates": candidates,
|
|
||||||
"best_candidate_index": best_idx,
|
|
||||||
"queries_used": search_queries,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in redownload source search: {e}", exc_info=True)
|
logger.error(f"Error in redownload source search: {e}", exc_info=True)
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
|
||||||
|
|
@ -42982,71 +42982,260 @@ function _renderRedownloadStep1(overlay, track, data) {
|
||||||
overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active'));
|
overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active'));
|
||||||
overlay.querySelector('.redownload-step[data-step="2"]').classList.add('active');
|
overlay.querySelector('.redownload-step[data-step="2"]').classList.add('active');
|
||||||
|
|
||||||
body.innerHTML = '<div class="redownload-loading"><div class="server-search-spinner"></div>Searching download sources...</div>';
|
// Stream results from all download sources — columns appear as each source responds
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="rdl-src-columns" id="rdl-src-columns">
|
||||||
|
<div class="redownload-loading" id="rdl-src-loading"><div class="server-search-spinner"></div>Searching download sources...</div>
|
||||||
|
</div>
|
||||||
|
<label class="redownload-delete-old">
|
||||||
|
<input type="checkbox" id="redownload-delete-old-check" checked>
|
||||||
|
Delete old file after successful download
|
||||||
|
</label>
|
||||||
|
<div class="redownload-actions">
|
||||||
|
<button class="redownload-btn secondary" onclick="document.getElementById('redownload-overlay')?.remove()">Cancel</button>
|
||||||
|
<button class="redownload-btn primary" id="redownload-start-btn" disabled>Waiting for results...</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
try {
|
_streamRedownloadSources(overlay, track, selectedMeta);
|
||||||
const res = await fetch(`/api/library/track/${track.id}/redownload/search-sources`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ metadata: selectedMeta })
|
|
||||||
});
|
|
||||||
const srcData = await res.json();
|
|
||||||
if (!srcData.success) throw new Error(srcData.error);
|
|
||||||
_renderRedownloadStep2(overlay, track, selectedMeta, srcData);
|
|
||||||
} catch (e) {
|
|
||||||
body.innerHTML = `<div class="redownload-error">Source search failed: ${_esc(e.message)}</div>`;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderRedownloadStep2(overlay, track, metadata, srcData) {
|
async function _streamRedownloadSources(overlay, track, metadata) {
|
||||||
const body = document.getElementById('redownload-body');
|
const columnsEl = document.getElementById('rdl-src-columns');
|
||||||
if (!body) return;
|
const loadingEl = document.getElementById('rdl-src-loading');
|
||||||
|
const startBtn = document.getElementById('redownload-start-btn');
|
||||||
|
if (!columnsEl) return;
|
||||||
|
|
||||||
const candidates = srcData.candidates || [];
|
const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer_dl: '💜', hybrid: '⚡' };
|
||||||
if (candidates.length === 0) {
|
const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Auto' };
|
||||||
body.innerHTML = `
|
|
||||||
<div class="redownload-empty">No download sources found for this track.</div>
|
let allCandidates = [];
|
||||||
<div class="redownload-actions">
|
let firstResult = true;
|
||||||
<button class="redownload-btn secondary" onclick="document.getElementById('redownload-overlay')?.remove()">Close</button>
|
let bestGlobalIdx = -1;
|
||||||
</div>`;
|
|
||||||
return;
|
try {
|
||||||
|
const res = await fetch(`/api/library/track/${track.id}/redownload/search-sources`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ metadata })
|
||||||
|
});
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop(); // keep incomplete line
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(line);
|
||||||
|
if (data.done) continue;
|
||||||
|
|
||||||
|
const svc = data.source;
|
||||||
|
const candidates = data.candidates || [];
|
||||||
|
|
||||||
|
// Remove loading spinner on first result
|
||||||
|
if (firstResult && loadingEl) { loadingEl.remove(); firstResult = false; }
|
||||||
|
|
||||||
|
// Assign global indices
|
||||||
|
const startIdx = allCandidates.length;
|
||||||
|
candidates.forEach((c, i) => { c._globalIdx = startIdx + i; });
|
||||||
|
allCandidates.push(...candidates);
|
||||||
|
|
||||||
|
// Find best overall candidate
|
||||||
|
bestGlobalIdx = -1;
|
||||||
|
let bestConf = 0;
|
||||||
|
allCandidates.forEach((c, i) => {
|
||||||
|
if (!c.blacklisted && c.confidence > bestConf) { bestConf = c.confidence; bestGlobalIdx = i; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render column for this source
|
||||||
|
const icon = serviceIcons[svc] || '📦';
|
||||||
|
const label = serviceLabels[svc] || svc;
|
||||||
|
|
||||||
|
const itemsHtml = candidates.length === 0
|
||||||
|
? '<div class="rdl-src-col-empty">No results</div>'
|
||||||
|
: candidates.slice(0, 10).map(c => {
|
||||||
|
const confPct = Math.round((c.confidence || 0) * 100);
|
||||||
|
const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
|
||||||
|
const isRec = c._globalIdx === bestGlobalIdx;
|
||||||
|
const blClass = c.blacklisted ? ' blacklisted' : '';
|
||||||
|
const dur = c.duration ? `${Math.floor(c.duration / 60000)}:${String(Math.floor((c.duration % 60000) / 1000)).padStart(2, '0')}` : '';
|
||||||
|
return `
|
||||||
|
<label class="rdl-src-item${blClass}${isRec ? ' recommended' : ''}">
|
||||||
|
${c.blacklisted ? '<div class="rdl-src-radio-placeholder"></div>' : `<input type="radio" name="source-choice" value="${c._globalIdx}" ${isRec ? 'checked' : ''}>`}
|
||||||
|
<div class="rdl-src-item-body">
|
||||||
|
<div class="rdl-src-item-top">
|
||||||
|
<div class="rdl-src-item-name" title="${_esc(c.filename)}">${_esc(c.display_name)}</div>
|
||||||
|
${isRec ? '<span class="rdl-src-recommended">Best</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="rdl-src-item-details">
|
||||||
|
${c.quality ? `<span class="rdl-src-fmt">${c.quality}</span>` : ''}
|
||||||
|
${c.bitrate ? `<span class="rdl-src-detail">${c.bitrate}k</span>` : ''}
|
||||||
|
<span class="rdl-src-detail">${c.size_display}</span>
|
||||||
|
${dur ? `<span class="rdl-src-detail">${dur}</span>` : ''}
|
||||||
|
${svc === 'soulseek' ? `<span class="rdl-src-detail rdl-src-user">${_esc(c.username)}</span>` : ''}
|
||||||
|
${svc === 'soulseek' && c.free_upload_slots != null ? `<span class="rdl-src-detail">${c.free_upload_slots} slots</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="rdl-src-conf-bar"><div class="rdl-src-conf-fill ${confCls}" style="width:${confPct}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="rdl-src-conf-pct ${confCls}">${confPct}%</div>
|
||||||
|
${c.blacklisted ? '<span class="rdl-src-bl">Blacklisted</span>' : ''}
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const colEl = document.createElement('div');
|
||||||
|
colEl.className = 'rdl-src-col';
|
||||||
|
colEl.style.animation = 'fadeSlideUp 0.3s ease both';
|
||||||
|
colEl.innerHTML = `
|
||||||
|
<div class="rdl-src-col-header">
|
||||||
|
<span class="rdl-src-col-icon">${icon}</span>
|
||||||
|
<span class="rdl-src-col-label">${label}</span>
|
||||||
|
<span class="rdl-src-col-count">${candidates.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rdl-src-col-body">${itemsHtml}</div>
|
||||||
|
`;
|
||||||
|
columnsEl.appendChild(colEl);
|
||||||
|
|
||||||
|
// Enable the download button
|
||||||
|
if (startBtn && allCandidates.some(c => !c.blacklisted)) {
|
||||||
|
startBtn.disabled = false;
|
||||||
|
startBtn.textContent = 'Download Selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) { /* skip malformed lines */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (loadingEl) loadingEl.innerHTML = `<div class="redownload-error">Error: ${_esc(e.message)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bestIdx = srcData.best_candidate_index >= 0 ? srcData.best_candidate_index : 0;
|
// If no results at all
|
||||||
|
if (allCandidates.length === 0 && loadingEl) {
|
||||||
|
loadingEl.innerHTML = '<div class="rdl-src-col-empty">No download sources found for this track.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
const candidatesHtml = candidates.map((c, i) => {
|
// Store candidates for the download button
|
||||||
const confPct = Math.round((c.confidence || 0) * 100);
|
window._redownloadCandidates = allCandidates;
|
||||||
const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
|
|
||||||
const checked = (i === bestIdx && !c.blacklisted) ? 'checked' : '';
|
// Wire up download button
|
||||||
const blClass = c.blacklisted ? ' blacklisted' : '';
|
const startBtn2 = document.getElementById('redownload-start-btn');
|
||||||
|
if (startBtn2) {
|
||||||
|
startBtn2.addEventListener('click', async () => {
|
||||||
|
const checked = document.querySelector('input[name="source-choice"]:checked');
|
||||||
|
if (!checked) { showToast('Select a download source', 'error'); return; }
|
||||||
|
const candidate = window._redownloadCandidates[parseInt(checked.value)];
|
||||||
|
const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true;
|
||||||
|
|
||||||
|
overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active'));
|
||||||
|
overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active');
|
||||||
|
|
||||||
|
const body = document.getElementById('redownload-body');
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="redownload-progress">
|
||||||
|
<div class="redownload-progress-title">Downloading: ${_esc(candidate.display_name)}</div>
|
||||||
|
<div class="redownload-progress-from">from ${_esc(candidate.source_service === 'soulseek' ? candidate.username : (candidate.source_service || 'unknown'))}</div>
|
||||||
|
<div class="redownload-progress-bar-wrap"><div class="redownload-progress-bar" id="redownload-progress-bar"></div></div>
|
||||||
|
<div class="redownload-progress-status" id="redownload-progress-status">Starting download...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/library/track/${track.id}/redownload/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ metadata, candidate, delete_old_file: deleteOld })
|
||||||
|
});
|
||||||
|
const startData = await res.json();
|
||||||
|
if (!startData.success) throw new Error(startData.error);
|
||||||
|
_pollRedownloadProgress(startData.task_id, overlay);
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML = `<div class="redownload-error">Download failed: ${_esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* _renderRedownloadStep2 removed — replaced by _streamRedownloadSources above */
|
||||||
|
if (false) {
|
||||||
|
const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer_dl: '💜', hybrid: '⚡' };
|
||||||
|
const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Auto' };
|
||||||
|
|
||||||
|
// Group candidates by source service
|
||||||
|
const grouped = {};
|
||||||
|
candidates.forEach((c, i) => {
|
||||||
|
c._origIdx = i; // preserve original index for radio value
|
||||||
|
const svc = c.source_service || 'unknown';
|
||||||
|
if (!grouped[svc]) grouped[svc] = [];
|
||||||
|
grouped[svc].push(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build columns — one per source
|
||||||
|
const sourceColumnsHtml = Object.entries(grouped).map(([svc, items]) => {
|
||||||
|
const icon = serviceIcons[svc] || '📦';
|
||||||
|
const label = serviceLabels[svc] || svc;
|
||||||
|
|
||||||
|
const itemsHtml = items.slice(0, 10).map(c => {
|
||||||
|
const confPct = Math.round((c.confidence || 0) * 100);
|
||||||
|
const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
|
||||||
|
const isRecommended = c._origIdx === bestIdx && !c.blacklisted;
|
||||||
|
const checked = isRecommended ? 'checked' : '';
|
||||||
|
const blClass = c.blacklisted ? ' blacklisted' : '';
|
||||||
|
const dur = c.duration ? `${Math.floor(c.duration / 60000)}:${String(Math.floor((c.duration % 60000) / 1000)).padStart(2, '0')}` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<label class="rdl-src-item${blClass}${isRecommended ? ' recommended' : ''}" data-index="${c._origIdx}">
|
||||||
|
${c.blacklisted ? '<div class="rdl-src-radio-placeholder"></div>' : `<input type="radio" name="source-choice" value="${c._origIdx}" ${checked}>`}
|
||||||
|
<div class="rdl-src-item-body">
|
||||||
|
<div class="rdl-src-item-top">
|
||||||
|
<div class="rdl-src-item-name" title="${_esc(c.filename)}">${_esc(c.display_name)}</div>
|
||||||
|
${isRecommended ? '<span class="rdl-src-recommended">Best Match</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="rdl-src-item-details">
|
||||||
|
${c.quality ? `<span class="rdl-src-fmt">${c.quality}</span>` : ''}
|
||||||
|
${c.bitrate ? `<span class="rdl-src-detail">${c.bitrate}k</span>` : ''}
|
||||||
|
<span class="rdl-src-detail">${c.size_display}</span>
|
||||||
|
${dur ? `<span class="rdl-src-detail">${dur}</span>` : ''}
|
||||||
|
${svc === 'soulseek' ? `<span class="rdl-src-detail rdl-src-user">${_esc(c.username)}</span>` : ''}
|
||||||
|
${svc === 'soulseek' ? `<span class="rdl-src-detail">${c.free_upload_slots || 0} slots</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="rdl-src-conf-bar">
|
||||||
|
<div class="rdl-src-conf-fill ${confCls}" style="width:${confPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rdl-src-conf-pct ${confCls}">${confPct}%</div>
|
||||||
|
${c.blacklisted ? '<span class="rdl-src-bl">Blacklisted</span>' : ''}
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<label class="redownload-candidate${blClass}" data-index="${i}">
|
<div class="rdl-src-col">
|
||||||
${c.blacklisted ? '' : `<input type="radio" name="source-choice" value="${i}" ${checked}>`}
|
<div class="rdl-src-col-header">
|
||||||
<div class="redownload-candidate-info">
|
<span class="rdl-src-col-icon">${icon}</span>
|
||||||
<div class="redownload-candidate-name">${_esc(c.display_name)}</div>
|
<span class="rdl-src-col-label">${label}</span>
|
||||||
<div class="redownload-candidate-meta">${_esc(c.username)} · ${c.free_upload_slots || 0} slots${c.queue_length > 0 ? ` · ${c.queue_length} in queue` : ''}</div>
|
<span class="rdl-src-col-count">${items.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="redownload-candidate-details">
|
<div class="rdl-src-col-body">${itemsHtml}</div>
|
||||||
${c.quality ? `<span class="redownload-fmt">${c.quality}</span>` : ''}
|
</div>`;
|
||||||
${c.bitrate ? `<span class="redownload-bitrate">${c.bitrate}k</span>` : ''}
|
|
||||||
<span class="redownload-size">${c.size_display}</span>
|
|
||||||
</div>
|
|
||||||
<div class="redownload-candidate-conf ${confCls}">${confPct}%</div>
|
|
||||||
${c.blacklisted ? '<span class="redownload-blacklisted">Blacklisted</span>' : ''}
|
|
||||||
</label>`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="redownload-candidates-wrap">${candidatesHtml}</div>
|
<div class="rdl-src-columns">${sourceColumnsHtml}</div>
|
||||||
<label class="redownload-delete-old">
|
<label class="redownload-delete-old">
|
||||||
<input type="checkbox" id="redownload-delete-old-check" checked>
|
<input type="checkbox" id="redownload-delete-old-check" checked>
|
||||||
Delete old file after successful download
|
Delete old file after successful download
|
||||||
</label>
|
</label>
|
||||||
<div class="redownload-actions">
|
<div class="redownload-actions">
|
||||||
<button class="redownload-btn secondary" onclick="document.getElementById('redownload-overlay')?.remove()">Cancel</button>
|
<button class="redownload-btn secondary" onclick="document.getElementById('redownload-overlay')?.remove()">Cancel</button>
|
||||||
<button class="redownload-btn primary" id="redownload-start-btn">Start Download</button>
|
<button class="redownload-btn primary" id="redownload-start-btn">Download Selected</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52223,35 +52223,108 @@ tr.tag-diff-same {
|
||||||
.redownload-result-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex-shrink: 0; }
|
.redownload-result-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex-shrink: 0; }
|
||||||
.redownload-result-dur { font-size: 10px; color: rgba(255,255,255,0.2); flex-shrink: 0; min-width: 30px; text-align: right; }
|
.redownload-result-dur { font-size: 10px; color: rgba(255,255,255,0.2); flex-shrink: 0; min-width: 30px; text-align: right; }
|
||||||
|
|
||||||
/* Step 2 — Download candidates */
|
/* Step 2 — Download source columns */
|
||||||
.redownload-candidates-wrap { max-height: 450px; overflow-y: auto; }
|
.rdl-src-columns {
|
||||||
.redownload-candidates-wrap::-webkit-scrollbar { width: 4px; }
|
display: grid;
|
||||||
.redownload-candidates-wrap::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; }
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
.redownload-candidate {
|
|
||||||
display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 12px;
|
|
||||||
cursor: pointer; transition: all 0.15s; margin-bottom: 3px; border: 1px solid transparent;
|
|
||||||
}
|
}
|
||||||
.redownload-candidate:hover { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.06); }
|
|
||||||
.redownload-candidate:has(input:checked) { background: rgba(var(--accent-rgb), 0.06); border-color: rgba(var(--accent-rgb), 0.15); }
|
|
||||||
.redownload-candidate input[type="radio"] { accent-color: var(--accent); flex-shrink: 0; width: 16px; height: 16px; }
|
|
||||||
.redownload-candidate.blacklisted { opacity: 0.35; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.redownload-candidate-info { flex: 1; min-width: 0; }
|
.rdl-src-col {
|
||||||
.redownload-candidate-name { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
.redownload-candidate-meta { font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 2px; }
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.redownload-candidate-details { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
.rdl-src-col-header {
|
||||||
.redownload-fmt { font-size: 9px; font-weight: 700; padding: 2px 5px; border-radius: 3px; background: rgba(var(--accent-rgb),0.12); color: var(--accent); }
|
display: flex; align-items: center; gap: 8px;
|
||||||
.redownload-bitrate { font-size: 9px; color: rgba(255,255,255,0.25); }
|
padding: 12px 16px;
|
||||||
.redownload-size { font-size: 9px; color: rgba(255,255,255,0.2); }
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 13px; font-weight: 700; color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.rdl-src-col-icon { font-size: 16px; }
|
||||||
|
.rdl-src-col-label { flex: 1; }
|
||||||
|
.rdl-src-col-count {
|
||||||
|
font-size: 10px; padding: 2px 8px; border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.redownload-candidate-conf { font-size: 10px; font-weight: 700; flex-shrink: 0; padding: 2px 6px; border-radius: 4px; }
|
.rdl-src-col-body {
|
||||||
.redownload-candidate-conf.high { background: rgba(76,175,80,0.12); color: #4caf50; }
|
max-height: 400px; overflow-y: auto; padding: 6px;
|
||||||
.redownload-candidate-conf.medium { background: rgba(255,183,77,0.12); color: #ffb74d; }
|
}
|
||||||
.redownload-candidate-conf.low { background: rgba(239,83,80,0.12); color: #ef5350; }
|
.rdl-src-col-body::-webkit-scrollbar { width: 3px; }
|
||||||
|
.rdl-src-col-body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 2px; }
|
||||||
|
|
||||||
.redownload-blacklisted { font-size: 9px; color: #ef5350; font-weight: 600; flex-shrink: 0; }
|
/* Individual result item */
|
||||||
|
.rdl-src-item {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
padding: 10px 12px; border-radius: 12px; cursor: pointer;
|
||||||
|
transition: all 0.15s; margin-bottom: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.rdl-src-item:hover { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.06); }
|
||||||
|
.rdl-src-item:has(input:checked) { background: rgba(var(--accent-rgb), 0.06); border-color: rgba(var(--accent-rgb), 0.15); }
|
||||||
|
.rdl-src-item.recommended { border-color: rgba(var(--accent-rgb), 0.1); }
|
||||||
|
.rdl-src-item.blacklisted { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.rdl-src-item input[type="radio"] { accent-color: var(--accent); flex-shrink: 0; width: 16px; height: 16px; margin-top: 4px; }
|
||||||
|
.rdl-src-radio-placeholder { width: 16px; height: 16px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.rdl-src-item-body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.rdl-src-item-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||||
|
.rdl-src-item-name {
|
||||||
|
font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.9);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.rdl-src-recommended {
|
||||||
|
font-size: 8px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.15); color: var(--accent);
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdl-src-item-details {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.rdl-src-fmt {
|
||||||
|
font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 4px;
|
||||||
|
background: rgba(var(--accent-rgb), 0.12); color: var(--accent); letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.rdl-src-detail {
|
||||||
|
font-size: 10px; color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.rdl-src-user {
|
||||||
|
color: rgba(255, 255, 255, 0.45); font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confidence bar */
|
||||||
|
.rdl-src-conf-bar {
|
||||||
|
height: 3px; background: rgba(255, 255, 255, 0.06); border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.rdl-src-conf-fill {
|
||||||
|
height: 100%; border-radius: 2px; transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.rdl-src-conf-fill.high { background: #4caf50; }
|
||||||
|
.rdl-src-conf-fill.medium { background: #ffb74d; }
|
||||||
|
.rdl-src-conf-fill.low { background: #ef5350; }
|
||||||
|
|
||||||
|
.rdl-src-conf-pct {
|
||||||
|
font-size: 11px; font-weight: 700; flex-shrink: 0; min-width: 34px; text-align: right; margin-top: 2px;
|
||||||
|
}
|
||||||
|
.rdl-src-conf-pct.high { color: #4caf50; }
|
||||||
|
.rdl-src-conf-pct.medium { color: #ffb74d; }
|
||||||
|
.rdl-src-conf-pct.low { color: #ef5350; }
|
||||||
|
|
||||||
|
.rdl-src-bl { font-size: 9px; color: #ef5350; font-weight: 600; margin-top: 2px; }
|
||||||
|
|
||||||
|
.rdl-src-col-empty {
|
||||||
|
padding: 24px 16px; text-align: center;
|
||||||
|
font-size: 12px; color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.redownload-delete-old {
|
.redownload-delete-old {
|
||||||
display: flex; align-items: center; gap: 8px; padding: 12px 0 4px;
|
display: flex; align-items: center; gap: 8px; padding: 12px 0 4px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue