Merge origin/fix/downloads-page into dev

This commit is contained in:
JohnBaumb 2026-04-23 11:13:45 -07:00
commit a0eb289d63
3 changed files with 193 additions and 23 deletions

View file

@ -31570,13 +31570,26 @@ def get_all_downloads_unified():
album = str(raw_album) if raw_album else ''
artwork = track_info.get('artwork_url') or track_info.get('image_url') or track_info.get('album_art') or ''
# Try album images
# Try album images (both image_url and images[] formats)
if not artwork:
raw_alb = track_info.get('album')
if isinstance(raw_alb, dict):
images = raw_alb.get('images') or []
if images and isinstance(images, list) and len(images) > 0:
artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
artwork = raw_alb.get('image_url') or ''
if not artwork:
images = raw_alb.get('images') or []
if images and isinstance(images, list) and len(images) > 0:
artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
# Try spotify_data nested object (wishlist tracks store metadata here)
if not artwork:
sp_data = track_info.get('spotify_data') or {}
if isinstance(sp_data, dict):
sp_album = sp_data.get('album') or {}
if isinstance(sp_album, dict):
artwork = sp_album.get('image_url') or ''
if not artwork:
sp_imgs = sp_album.get('images') or []
if sp_imgs and isinstance(sp_imgs, list) and isinstance(sp_imgs[0], dict):
artwork = sp_imgs[0].get('url', '')
status = task.get('status', 'queued')
# Determine download progress percentage
@ -31620,6 +31633,86 @@ def get_all_downloads_unified():
# Sort: active first (by priority), then by timestamp desc within each group
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
# POST-LOCK: Enrich missing artwork from metadata cache (handles legacy
# wishlist tracks that were stored without image data)
items_needing_art = [it for it in items if not it.get('artwork') and it.get('title')]
if items_needing_art:
try:
database = get_database()
with database._get_connection() as conn:
cursor = conn.cursor()
# 1. Batch lookup by track name
track_names = list({it['title'] for it in items_needing_art if it['title']})
name_to_art = {}
if track_names:
placeholders = ','.join('?' for _ in track_names)
cursor.execute(f"""
SELECT name, image_url FROM metadata_cache_entities
WHERE entity_type='track' AND name IN ({placeholders})
AND image_url IS NOT NULL AND image_url != ''
""", track_names)
for row in cursor.fetchall():
if row[0] not in name_to_art:
name_to_art[row[0]] = row[1]
for it in items_needing_art:
cached_url = name_to_art.get(it['title'])
if cached_url:
it['artwork'] = cached_url
# 2. Remaining items: look up by album name (album art is better than nothing)
still_needing = [it for it in items_needing_art if not it.get('artwork') and it.get('album')]
if still_needing:
album_names = list({it['album'] for it in still_needing if it['album']})
if album_names:
placeholders = ','.join('?' for _ in album_names)
cursor.execute(f"""
SELECT name, image_url FROM metadata_cache_entities
WHERE entity_type='album' AND name IN ({placeholders})
AND image_url IS NOT NULL AND image_url != ''
""", album_names)
album_to_art = {}
for row in cursor.fetchall():
if row[0] not in album_to_art:
album_to_art[row[0]] = row[1]
for it in still_needing:
cached_url = album_to_art.get(it['album'])
if cached_url:
it['artwork'] = cached_url
# 3. Last resort: check matched_downloads_context for items with active downloads
final_needing = [it for it in items_needing_art if not it.get('artwork')]
if final_needing:
with matched_context_lock:
for it in final_needing:
# Find task in download_tasks to get username/filename for context lookup
task_id = it.get('task_id')
if not task_id:
continue
with tasks_lock:
task = download_tasks.get(task_id)
if not task:
continue
t_username = task.get('username')
t_filename = task.get('filename')
if t_username and t_filename:
ctx_key = _make_context_key(t_username, t_filename)
ctx = matched_downloads_context.get(ctx_key)
if ctx:
_sp_album = ctx.get('spotify_album') or {}
_sp_track = ctx.get('track_info') or {}
_sp_images = _sp_album.get('images') or []
_art = ''
if _sp_images and isinstance(_sp_images[0], dict):
_art = _sp_images[0].get('url', '') or ''
if not _art:
_art = _sp_album.get('image_url') or ''
if not _art:
_art = _sp_track.get('image_url') or ''
if _art:
it['artwork'] = _art
except Exception as cache_err:
logger.debug(f"Metadata cache artwork lookup failed: {cache_err}")
# Build batch summaries for the batch context panel
batch_summaries = []
with tasks_lock:
@ -31634,6 +31727,7 @@ def get_all_downloads_unified():
'phase': batch.get('phase', 'unknown'),
'total': len(queue),
'analysis_total': batch.get('analysis_total', len(queue)),
'analysis_processed': batch.get('analysis_processed', 0),
'completed': sum(1 for s in statuses if s in ('completed', 'skipped', 'already_owned')),
'failed': sum(1 for s in statuses if s in ('failed', 'not_found', 'cancelled')),
'active': sum(1 for s in statuses if s in ('downloading', 'searching', 'post_processing')),
@ -31680,9 +31774,16 @@ def clear_completed_downloads():
for tid in task_ids_to_remove:
del download_tasks[tid]
cleared += 1
# Also clean up empty batches
# Also clean up empty batches (but not those still actively processing)
active_phases = {'analysis', 'downloading', 'queued'}
empty_batches = []
for bid, batch in download_batches.items():
# Never remove batches that are still actively working
if batch.get('phase') in active_phases:
# Still prune cleared tasks from queue
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
batch['queue'] = remaining
continue
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
if not remaining:
empty_batches.append(bid)

View file

@ -2488,6 +2488,10 @@ function _adlRenderBatchPanel() {
const hasFailed = batch.failed > 0;
const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error';
const isActive = batch.phase === 'downloading' && batch.active > 0;
const isAnalyzing = batch.phase === 'analysis';
const analysisTotal = batch.analysis_total || 0;
const analysisProcessed = batch.analysis_processed || 0;
const analysisPct = analysisTotal > 0 ? Math.round((analysisProcessed / analysisTotal) * 100) : 0;
// Fade progress for completing batches
let fadeStyle = '';
@ -2511,7 +2515,7 @@ function _adlRenderBatchPanel() {
phaseText = 'Queued';
phaseIcon = '<span style="color:#eab308;margin-right:4px">⏳</span>';
} else if (batch.phase === 'analysis') {
phaseText = 'Analyzing...';
phaseText = analysisTotal > 0 ? `Analyzing ${analysisProcessed}/${analysisTotal}...` : 'Analyzing...';
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
} else if (batch.phase === 'downloading') {
phaseText = `${batch.completed}/${total} tracks`;
@ -2610,7 +2614,7 @@ function _adlRenderBatchPanel() {
</div>
</div>
<div class="adl-batch-progress">
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${isAnalyzing ? analysisPct : pct}%"></div>
</div>
<div class="adl-batch-tracks">${tracksHtml}</div>
</div>`;
@ -2830,10 +2834,21 @@ function _adlRenderBatchHistory() {
list.innerHTML = _adlBatchHistory.map(h => {
const name = _adlEsc(h.playlist_name || 'Unknown');
const downloaded = h.tracks_downloaded || 0;
const found = h.tracks_found || 0;
const failed = h.tracks_failed || 0;
const total = h.total_tracks || 0;
const statsParts = [`${downloaded}/${total}`];
if (failed > 0) statsParts.push(`<span style="color:#ef4444">${failed} failed</span>`);
// Build stats line: "X owned · X new · X failed · X missing"
const statsParts = [];
if (found > 0 || downloaded > 0) {
const owned = found - downloaded; // already in library before this sync
if (owned > 0) statsParts.push(`<span style="color:rgba(74,222,128,0.7)" title="Already in library">${owned} owned</span>`);
if (downloaded > 0) statsParts.push(`<span style="color:rgba(96,165,250,0.7)" title="Newly downloaded">${downloaded} new</span>`);
}
if (failed > 0) statsParts.push(`<span style="color:#ef4444" title="Failed to download">${failed} failed</span>`);
const notFound = total - found - failed;
if (notFound > 0) statsParts.push(`<span title="Not found on any source">${notFound} missing</span>`);
if (statsParts.length === 0) statsParts.push(`${total} tracks`);
let dateText = '';
if (h.completed_at) {
@ -2850,18 +2865,50 @@ function _adlRenderBatchHistory() {
}
}
const sourceLabel = h.source_page ? `<span class="adl-batch-card-source" style="font-size:0.6rem;padding:0 4px">${_adlEsc(h.source_page)}</span>` : '';
// Use source (spotify, mirrored, discover, etc.) for badge when available, fall back to source_page
const badgeLabel = h.source || h.source_page || '';
const sourceLabel = badgeLabel ? `<span class="adl-batch-card-source" style="font-size:0.6rem;padding:0 4px">${_adlEsc(badgeLabel)}</span>` : '';
// Source type color dot
const sourceColors = { wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129' };
const dotColor = sourceColors[h.source_page] || '255, 255, 255';
// Source type color dot - expanded palette
const sourceColors = {
wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129',
discover: '251, 191, 36', mirrored: '236, 72, 153', spotify: '30, 215, 96',
youtube: '255, 0, 0', tidal: '0, 255, 255', deezer: '162, 73, 255',
beatport: '148, 252, 19', listenbrainz: '255, 134, 0'
};
const dotColor = sourceColors[h.source] || sourceColors[h.source_page] || '255, 255, 255';
const histDot = `<span class="adl-batch-history-dot" style="background:rgba(${dotColor}, 0.6)"></span>`;
// Optional thumbnail
const thumb = h.thumb_url
? `<img src="${_adlEsc(h.thumb_url)}" class="adl-batch-history-thumb" loading="lazy">`
: '';
// Server push status indicator
let pushBadge = '';
if (h.server_push_status) {
const pushIcons = {
pending: ['⏳', 'rgba(251,191,36,0.7)', 'Waiting to push to server'],
pushing: ['⬆', 'rgba(96,165,250,0.7)', 'Pushing to server...'],
success: ['✓', 'rgba(74,222,128,0.7)', 'Pushed to server'],
failed: ['✗', 'rgba(239,68,68,0.7)', 'Server push failed'],
skipped: ['—', 'rgba(255,255,255,0.2)', 'Server push skipped'],
};
const [icon, color, tip] = pushIcons[h.server_push_status] || ['?', 'rgba(255,255,255,0.3)', h.server_push_status];
pushBadge = ` · <span style="color:${color}" title="${tip}">${icon} server</span>`;
}
return `<div class="adl-batch-history-item">
${histDot}
<div class="adl-batch-history-name">${name} ${sourceLabel}</div>
<div class="adl-batch-history-stats">${statsParts.join(' ')}</div>
<div class="adl-batch-history-date">${dateText}</div>
${thumb}
<div class="adl-batch-history-content">
<div class="adl-batch-history-row1">
${histDot}
<span class="adl-batch-history-name">${name}</span>
${sourceLabel}
<span class="adl-batch-history-date">${dateText}</span>
</div>
<div class="adl-batch-history-row2">${statsParts.join(' · ')}${pushBadge}</div>
</div>
</div>`;
}).join('');
}

View file

@ -56809,7 +56809,7 @@ body.reduce-effects *::after {
/* ---- Batch Context Panel ---- */
.adl-batch-panel {
width: 340px;
width: 400px;
flex-shrink: 0;
border-left: 1px solid rgba(255, 255, 255, 0.06);
padding: 0 12px 0 24px;
@ -57188,29 +57188,51 @@ body.reduce-effects *::after {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
padding: 7px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
}
.adl-batch-history-name {
.adl-batch-history-thumb {
width: 28px;
height: 28px;
border-radius: 3px;
object-fit: cover;
flex-shrink: 0;
}
.adl-batch-history-content {
flex: 1;
min-width: 0;
}
.adl-batch-history-row1 {
display: flex;
align-items: center;
gap: 6px;
}
.adl-batch-history-name {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
color: rgba(255, 255, 255, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.adl-batch-history-stats {
.adl-batch-history-row2 {
font-size: 0.68rem;
color: rgba(255, 255, 255, 0.3);
flex-shrink: 0;
margin-top: 2px;
padding-left: 12px;
}
.adl-batch-history-date {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
margin-left: auto;
}
/* ---- Responsive ---- */