diff --git a/web_server.py b/web_server.py index 9c4eab35..92332780 100644 --- a/web_server.py +++ b/web_server.py @@ -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) diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 6428a1d0..d9a0a87b 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -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 = ''; } else if (batch.phase === 'analysis') { - phaseText = 'Analyzing...'; + phaseText = analysisTotal > 0 ? `Analyzing ${analysisProcessed}/${analysisTotal}...` : 'Analyzing...'; phaseIcon = ''; } else if (batch.phase === 'downloading') { phaseText = `${batch.completed}/${total} tracks`; @@ -2610,7 +2614,7 @@ function _adlRenderBatchPanel() {
-
+
${tracksHtml}
`; @@ -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(`${failed} failed`); + + // 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(`${owned} owned`); + if (downloaded > 0) statsParts.push(`${downloaded} new`); + } + if (failed > 0) statsParts.push(`${failed} failed`); + const notFound = total - found - failed; + if (notFound > 0) statsParts.push(`${notFound} missing`); + 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 ? `${_adlEsc(h.source_page)}` : ''; + // 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 ? `${_adlEsc(badgeLabel)}` : ''; - // 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 = ``; + // Optional thumbnail + const thumb = h.thumb_url + ? `` + : ''; + + // 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 = ` · ${icon} server`; + } + return `
- ${histDot} -
${name} ${sourceLabel}
-
${statsParts.join(' ')}
-
${dateText}
+ ${thumb} +
+
+ ${histDot} + ${name} + ${sourceLabel} + ${dateText} +
+
${statsParts.join(' · ')}${pushBadge}
+
`; }).join(''); } diff --git a/webui/static/style.css b/webui/static/style.css index 8f7448e1..98a93db1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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 ---- */