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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue