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
111
web_server.py
111
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
|
||||||
|
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)
|
track_objs = client.search_tracks(query, limit=10)
|
||||||
# If no results with full query, try title only
|
# Last resort: title only
|
||||||
if not track_objs and clean_title:
|
if not track_objs and clean_title != query:
|
||||||
logger.info(f"[Redownload] {source_name} got 0 results, trying title only: {clean_title}")
|
|
||||||
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,31 +13158,64 @@ 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
|
||||||
|
download_clients = {}
|
||||||
try:
|
try:
|
||||||
tracks_result, _ = run_async(soulseek_client.search(query, timeout=25))
|
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}")
|
||||||
|
|
||||||
|
if not download_clients:
|
||||||
|
# Fallback: use orchestrator directly
|
||||||
|
download_clients = {'default': soulseek_client}
|
||||||
|
|
||||||
|
logger.info(f"[Redownload] Streaming search across {len(download_clients)} sources: {list(download_clients.keys())}")
|
||||||
|
|
||||||
|
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:
|
if not tracks_result:
|
||||||
continue
|
continue
|
||||||
|
valid = get_valid_candidates(tracks_result, track_obj, q)
|
||||||
valid = get_valid_candidates(tracks_result, track_obj, query)
|
|
||||||
for candidate in valid:
|
for candidate in valid:
|
||||||
# Check blacklist
|
|
||||||
is_bl = database.is_blacklisted(candidate.username, candidate.filename)
|
is_bl = database.is_blacklisted(candidate.username, candidate.filename)
|
||||||
|
|
||||||
# Extract display name from filename
|
|
||||||
display_name = os.path.basename(candidate.filename.replace('\\', '/'))
|
display_name = os.path.basename(candidate.filename.replace('\\', '/'))
|
||||||
ext = os.path.splitext(display_name)[1].lstrip('.').upper()
|
ext = os.path.splitext(display_name)[1].lstrip('.').upper()
|
||||||
quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or ''
|
quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or ''
|
||||||
|
svc = source_name if source_name != 'default' else 'hybrid'
|
||||||
candidates.append({
|
uname = candidate.username
|
||||||
'username': candidate.username,
|
if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'):
|
||||||
|
svc = uname
|
||||||
|
source_candidates.append({
|
||||||
|
'username': uname,
|
||||||
'filename': candidate.filename,
|
'filename': candidate.filename,
|
||||||
'display_name': display_name,
|
'display_name': display_name,
|
||||||
'size': candidate.size or 0,
|
'size': candidate.size or 0,
|
||||||
|
|
@ -13184,36 +13224,43 @@ def redownload_search_sources(track_id):
|
||||||
'quality': quality,
|
'quality': quality,
|
||||||
'duration': candidate.duration or 0,
|
'duration': candidate.duration or 0,
|
||||||
'confidence': round(getattr(candidate, 'confidence', 0), 3),
|
'confidence': round(getattr(candidate, 'confidence', 0), 3),
|
||||||
'source_query': query,
|
'source_service': svc,
|
||||||
|
'source_query': q,
|
||||||
'blacklisted': is_bl,
|
'blacklisted': is_bl,
|
||||||
'free_upload_slots': getattr(candidate, 'free_upload_slots', 0),
|
'free_upload_slots': getattr(candidate, 'free_upload_slots', 0),
|
||||||
'upload_speed': getattr(candidate, 'upload_speed', 0),
|
'upload_speed': getattr(candidate, 'upload_speed', 0),
|
||||||
'queue_length': getattr(candidate, 'queue_length', 0),
|
'queue_length': getattr(candidate, 'queue_length', 0),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Download source search failed for query '{query}': {e}")
|
logger.debug(f"[Redownload] {source_name} search failed for query '{q}': {e}")
|
||||||
|
# Deduplicate within source
|
||||||
# Deduplicate by username+filename
|
|
||||||
seen = set()
|
seen = set()
|
||||||
unique = []
|
unique = []
|
||||||
for c in candidates:
|
for c in source_candidates:
|
||||||
key = f"{c['username']}|{c['filename']}"
|
key = f"{c['username']}|{c['filename']}"
|
||||||
if key not in seen:
|
if key not in seen:
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
unique.append(c)
|
unique.append(c)
|
||||||
candidates = unique
|
unique.sort(key=lambda c: (-int(not c['blacklisted']), -c['confidence']))
|
||||||
|
return unique
|
||||||
|
|
||||||
# Sort: non-blacklisted first, then by confidence
|
# Stream NDJSON — one line per source as it completes
|
||||||
candidates.sort(key=lambda c: (-int(not c['blacklisted']), -c['confidence']))
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
best_idx = next((i for i, c in enumerate(candidates) if not c['blacklisted']), 0) if candidates else -1
|
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'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderRedownloadStep2(overlay, track, metadata, srcData) {
|
|
||||||
const body = document.getElementById('redownload-body');
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
const candidates = srcData.candidates || [];
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="redownload-empty">No download sources found for this track.</div>
|
<div class="rdl-src-columns" id="rdl-src-columns">
|
||||||
<div class="redownload-actions">
|
<div class="redownload-loading" id="rdl-src-loading"><div class="server-search-spinner"></div>Searching download sources...</div>
|
||||||
<button class="redownload-btn secondary" onclick="document.getElementById('redownload-overlay')?.remove()">Close</button>
|
|
||||||
</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bestIdx = srcData.best_candidate_index >= 0 ? srcData.best_candidate_index : 0;
|
|
||||||
|
|
||||||
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' : '';
|
|
||||||
|
|
||||||
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>
|
</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>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
body.innerHTML = `
|
|
||||||
<div class="redownload-candidates-wrap">${candidatesHtml}</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" disabled>Waiting for results...</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
_streamRedownloadSources(overlay, track, selectedMeta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 `
|
||||||
|
<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="rdl-src-col-body">${itemsHtml}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<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">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