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:
Broque Thomas 2026-04-01 13:53:57 -07:00
parent 40f3621c48
commit 0fd6be3239
3 changed files with 435 additions and 126 deletions

View file

@ -13068,13 +13068,20 @@ def redownload_search_metadata(track_id):
except Exception as 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):
try:
logger.info(f"[Redownload] Searching {source_name} for: {query}")
track_objs = client.search_tracks(query, limit=10)
# If no results with full query, try title only
if not track_objs and clean_title:
logger.info(f"[Redownload] {source_name} got 0 results, trying title only: {clean_title}")
# Deezer works best with structured artist:/track: queries
search_q = deezer_query if source_name == 'deezer' else query
logger.info(f"[Redownload] Searching {source_name} for: {search_q}")
track_objs = client.search_tracks(search_q, limit=10)
# 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)
logger.info(f"[Redownload] {source_name} returned {len(track_objs)} results")
results = []
@ -13151,69 +13158,109 @@ def redownload_search_sources(track_id):
if not search_queries:
search_queries = [f"{metadata.get('artist', '')} {metadata['name']}".strip()]
# Limit to first 3 queries for speed
search_queries = search_queries[:3]
# Use first 2 queries for speed
search_queries = search_queries[:2]
# Search download sources
# Search ALL configured download sources individually (not through hybrid which stops at first hit)
candidates = []
database = get_database()
for qi, query in enumerate(search_queries):
try:
tracks_result, _ = run_async(soulseek_client.search(query, timeout=25))
if not tracks_result:
continue
# Get all available download source clients
download_clients = {}
try:
orch = soulseek_client # The download orchestrator
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)
for candidate in valid:
# Check blacklist
is_bl = database.is_blacklisted(candidate.username, candidate.filename)
if not download_clients:
# Fallback: use orchestrator directly
download_clients = {'default': soulseek_client}
# Extract display name from filename
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 ''
logger.info(f"[Redownload] Streaming search across {len(download_clients)} sources: {list(download_clients.keys())}")
candidates.append({
'username': candidate.username,
'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_query': query,
'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"Download source search failed for query '{query}': {e}")
def _search_one_source(source_name, client):
"""Search a single download source and return formatted candidates."""
source_candidates = []
for qi, q in enumerate(search_queries):
try:
tracks_result, _ = run_async(client.search(q, timeout=20))
if not tracks_result:
continue
valid = get_valid_candidates(tracks_result, track_obj, q)
for candidate in valid:
is_bl = database.is_blacklisted(candidate.username, candidate.filename)
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 ''
svc = source_name if source_name != 'default' else 'hybrid'
uname = candidate.username
if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'):
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
seen = set()
unique = []
for c in candidates:
key = f"{c['username']}|{c['filename']}"
if key not in seen:
seen.add(key)
unique.append(c)
candidates = unique
# Stream NDJSON — one line per source as it completes
from concurrent.futures import ThreadPoolExecutor, as_completed
# Sort: non-blacklisted first, then by confidence
candidates.sort(key=lambda c: (-int(not c['blacklisted']), -c['confidence']))
def generate_stream():
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:
logger.error(f"Error in redownload source search: {e}", exc_info=True)
return jsonify({"success": False, "error": str(e)}), 500

View file

@ -42982,71 +42982,260 @@ function _renderRedownloadStep1(overlay, track, data) {
overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('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 {
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>`;
}
_streamRedownloadSources(overlay, track, selectedMeta);
});
}
function _renderRedownloadStep2(overlay, track, metadata, srcData) {
const body = document.getElementById('redownload-body');
if (!body) return;
async function _streamRedownloadSources(overlay, track, metadata) {
const columnsEl = document.getElementById('rdl-src-columns');
const loadingEl = document.getElementById('rdl-src-loading');
const startBtn = document.getElementById('redownload-start-btn');
if (!columnsEl) return;
const candidates = srcData.candidates || [];
if (candidates.length === 0) {
body.innerHTML = `
<div class="redownload-empty">No download sources found for this track.</div>
<div class="redownload-actions">
<button class="redownload-btn secondary" onclick="document.getElementById('redownload-overlay')?.remove()">Close</button>
</div>`;
return;
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' };
let allCandidates = [];
let firstResult = true;
let bestGlobalIdx = -1;
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) => {
const confPct = Math.round((c.confidence || 0) * 100);
const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
const checked = (i === bestIdx && !c.blacklisted) ? 'checked' : '';
const blClass = c.blacklisted ? ' blacklisted' : '';
// Store candidates for the download button
window._redownloadCandidates = allCandidates;
// Wire up download button
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 `
<label class="redownload-candidate${blClass}" data-index="${i}">
${c.blacklisted ? '' : `<input type="radio" name="source-choice" value="${i}" ${checked}>`}
<div class="redownload-candidate-info">
<div class="redownload-candidate-name">${_esc(c.display_name)}</div>
<div class="redownload-candidate-meta">${_esc(c.username)} · ${c.free_upload_slots || 0} slots${c.queue_length > 0 ? ` · ${c.queue_length} in queue` : ''}</div>
<div class="rdl-src-col">
<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">${items.length}</span>
</div>
<div class="redownload-candidate-details">
${c.quality ? `<span class="redownload-fmt">${c.quality}</span>` : ''}
${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>`;
<div class="rdl-src-col-body">${itemsHtml}</div>
</div>`;
}).join('');
body.innerHTML = `
<div class="redownload-candidates-wrap">${candidatesHtml}</div>
<div class="rdl-src-columns">${sourceColumnsHtml}</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">Start Download</button>
<button class="redownload-btn primary" id="redownload-start-btn">Download Selected</button>
</div>
`;

View file

@ -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-dur { font-size: 10px; color: rgba(255,255,255,0.2); flex-shrink: 0; min-width: 30px; text-align: right; }
/* Step 2 — Download candidates */
.redownload-candidates-wrap { max-height: 450px; overflow-y: auto; }
.redownload-candidates-wrap::-webkit-scrollbar { width: 4px; }
.redownload-candidates-wrap::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; }
.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;
/* Step 2 — Download source columns */
.rdl-src-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
}
.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; }
.redownload-candidate-name { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.9); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.redownload-candidate-meta { font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 2px; }
.rdl-src-col {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.redownload-candidate-details { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.redownload-fmt { font-size: 9px; font-weight: 700; padding: 2px 5px; border-radius: 3px; background: rgba(var(--accent-rgb),0.12); color: var(--accent); }
.redownload-bitrate { font-size: 9px; color: rgba(255,255,255,0.25); }
.redownload-size { font-size: 9px; color: rgba(255,255,255,0.2); }
.rdl-src-col-header {
display: flex; align-items: center; gap: 8px;
padding: 12px 16px;
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; }
.redownload-candidate-conf.high { background: rgba(76,175,80,0.12); color: #4caf50; }
.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 {
max-height: 400px; overflow-y: auto; padding: 6px;
}
.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 {
display: flex; align-items: center; gap: 8px; padding: 12px 0 4px;