From 7798f5688572e7798f83e33c2aa9349b4f6179ef Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:58:22 -0700 Subject: [PATCH] Add centralized Downloads page with live status across all sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New sidebar page showing every download task across the app in a unified live-updating list. Tracks from Sync, Discover, Artists, Search, and Wishlist all appear in one place. Features: - Filter pills: All / Active / Queued / Completed / Failed - Section headers grouping by status category - Track position (3 of 19) for album/playlist batches - Album art, artist/album metadata, batch context, error messages - Status dots with accent glow for active, green for complete, red fail - Clear Completed button removes terminal items from tracker - Nav badge shows active download count from any page via WebSocket Fixes artist [object Object] display — handles all format variations (list of dicts, list of strings, dict, string) for artist and album fields in the API response. --- web_server.py | 150 +++++++++++++++++- webui/index.html | 32 ++++ webui/static/script.js | 222 +++++++++++++++++++++++++++ webui/static/style.css | 336 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 738 insertions(+), 2 deletions(-) diff --git a/web_server.py b/web_server.py index 24f3a02b..ab0eb6f4 100644 --- a/web_server.py +++ b/web_server.py @@ -4630,12 +4630,20 @@ def get_status(): download_mode = config_manager.get('download_source.mode', 'hybrid') _status_cache['soulseek']['source'] = download_mode + # Count active downloads for nav badge + active_dl_count = 0 + with tasks_lock: + for t in download_tasks.values(): + if t.get('status') in ('downloading', 'searching', 'post_processing', 'queued', 'pending'): + active_dl_count += 1 + status_data = { 'spotify': _status_cache['spotify'], 'media_server': _status_cache['media_server'], 'soulseek': _status_cache['soulseek'], 'active_media_server': active_server, - 'enrichment': _get_enrichment_status() + 'enrichment': _get_enrichment_status(), + 'active_downloads': active_dl_count, } return jsonify(status_data) except Exception as e: @@ -29180,6 +29188,133 @@ def get_batched_download_statuses(): traceback.print_exc() return jsonify({"error": str(e)}), 500 +@app.route('/api/downloads/all', methods=['GET']) +def get_all_downloads_unified(): + """ + Unified downloads list for the centralized Downloads page. + Returns a flat list of all download tasks across all batches, + sorted: downloading/searching first, then queued, then completed/failed. + """ + try: + limit = int(request.args.get('limit', 200)) + status_priority = { + 'downloading': 0, 'searching': 1, 'post_processing': 2, + 'queued': 3, 'pending': 3, + 'completed': 4, 'skipped': 5, 'already_owned': 5, + 'not_found': 6, 'failed': 7, 'cancelled': 8, + } + + items = [] + with tasks_lock: + for task_id, task in download_tasks.items(): + track_info = task.get('track_info') or {} + batch_id = task.get('batch_id', '') + batch = download_batches.get(batch_id, {}) + + # Extract track metadata — handle all format variations + title = '' + artist = '' + album = '' + artwork = '' + if isinstance(track_info, dict): + title = track_info.get('title') or track_info.get('name') or track_info.get('track_name') or '' + + # Artist can be: string, list of strings, list of dicts with 'name' + raw_artist = track_info.get('artist') or track_info.get('artist_name') or track_info.get('artists') or '' + if isinstance(raw_artist, list): + parts = [] + for a in raw_artist: + if isinstance(a, dict): + parts.append(a.get('name', '')) + else: + parts.append(str(a)) + artist = ', '.join(p for p in parts if p) + elif isinstance(raw_artist, dict): + artist = raw_artist.get('name', '') + else: + artist = str(raw_artist) if raw_artist else '' + + # Album can be: string or dict with 'name' + raw_album = track_info.get('album') or track_info.get('album_name') or '' + if isinstance(raw_album, dict): + album = raw_album.get('name', '') + else: + 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 + 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]) + + status = task.get('status', 'queued') + items.append({ + 'task_id': task_id, + 'title': title, + 'artist': artist, + 'album': album, + 'artwork': artwork, + 'status': status, + 'error': task.get('error_message'), + 'batch_id': batch_id, + 'batch_name': batch.get('playlist_name') or batch.get('album_name') or '', + 'batch_source': batch.get('source_page') or batch.get('initiated_from') or '', + 'track_index': task.get('track_index', 0), + 'batch_total': len(batch.get('queue', [])), + 'timestamp': task.get('status_change_time', 0), + 'priority': status_priority.get(status, 9), + }) + + # Sort: active first (by priority), then by timestamp desc within each group + items.sort(key=lambda x: (x['priority'], -x['timestamp'])) + + return jsonify({ + 'success': True, + 'downloads': items[:limit], + 'total': len(items), + 'timestamp': time.time(), + }) + except Exception as e: + logger.error(f"Error getting unified downloads: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/downloads/clear-completed', methods=['POST']) +def clear_completed_downloads(): + """Remove completed/failed/cancelled tasks from the download tracker.""" + try: + terminal_statuses = {'completed', 'failed', 'not_found', 'cancelled', 'skipped', 'already_owned'} + cleared = 0 + with tasks_lock: + task_ids_to_remove = [ + tid for tid, task in download_tasks.items() + if task.get('status') in terminal_statuses + ] + for tid in task_ids_to_remove: + del download_tasks[tid] + cleared += 1 + # Also clean up empty batches + empty_batches = [] + for bid, batch in download_batches.items(): + remaining = [t for t in batch.get('queue', []) if t in download_tasks] + if not remaining: + empty_batches.append(bid) + else: + batch['queue'] = remaining + for bid in empty_batches: + del download_batches[bid] + if bid in batch_locks: + del batch_locks[bid] + + return jsonify({'success': True, 'cleared': cleared}) + except Exception as e: + logger.error(f"Error clearing completed downloads: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/downloads/cancel_task', methods=['POST']) def cancel_download_task(): """ @@ -51793,12 +51928,23 @@ def _build_status_payload(): if cooldown_remaining > 0: spotify_data['post_ban_cooldown'] = cooldown_remaining + # Count active downloads for nav badge + active_dl_count = 0 + try: + with tasks_lock: + for t in download_tasks.values(): + if t.get('status') in ('downloading', 'searching', 'post_processing', 'queued', 'pending'): + active_dl_count += 1 + except Exception: + pass + return { 'spotify': spotify_data, 'media_server': _status_cache.get('media_server', {}), 'soulseek': soulseek_data, 'active_media_server': config_manager.get_active_media_server(), - 'enrichment': _get_enrichment_status() + 'enrichment': _get_enrichment_status(), + 'active_downloads': active_dl_count, } def _build_watchlist_count_payload(profile_id=1): diff --git a/webui/index.html b/webui/index.html index e8550438..0d2b677b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -102,6 +102,7 @@ + @@ -116,6 +117,7 @@ + @@ -214,6 +216,11 @@ Automations + + + + + + +
+ + +
+ + +
+
No downloads yet. Start one from Search, Sync, Discover, or Artists.
+
+ + +
diff --git a/webui/static/script.js b/webui/static/script.js index 86cadaac..a954a9e6 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -386,6 +386,9 @@ function handleServiceStatusUpdate(data) { updateSidebarServiceStatus('media-server', data.media_server); updateSidebarServiceStatus('soulseek', data.soulseek); + // Update downloads nav badge from status push + if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads); + // Update enrichment service cards if (data.enrichment) renderEnrichmentCards(data.enrichment); @@ -2935,6 +2938,9 @@ async function loadPageData(pageId) { restoreArtistsPageState(); } break; + case 'active-downloads': + loadActiveDownloadsPage(); + break; case 'library': // Check if we should return to artist detail view instead of list if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) { @@ -38727,6 +38733,9 @@ async function fetchAndUpdateServiceStatus() { updateSidebarServiceStatus('media-server', data.media_server); updateSidebarServiceStatus('soulseek', data.soulseek); + // Update downloads nav badge + if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads); + // Update enrichment service cards if (data.enrichment) renderEnrichmentCards(data.enrichment); @@ -72715,3 +72724,216 @@ function _syncDetailFilter(btn, filter) { } }); } + + +// ============================================ +// ACTIVE DOWNLOADS PAGE — Centralized Live View +// ============================================ + +let _adlPoller = null; +let _adlFilter = 'all'; +let _adlData = []; + +function loadActiveDownloadsPage() { + _adlFetch(); + // Poll every 2 seconds while on this page + if (_adlPoller) clearInterval(_adlPoller); + _adlPoller = setInterval(() => { + if (currentPage === 'active-downloads') _adlFetch(); + else { clearInterval(_adlPoller); _adlPoller = null; } + }, 2000); +} + +function adlSetFilter(filter) { + _adlFilter = filter; + document.querySelectorAll('#adl-filter-pills .adl-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === filter)); + _adlRender(); +} + +async function _adlFetch() { + try { + const resp = await fetch('/api/downloads/all?limit=300'); + const data = await resp.json(); + if (data.success) { + _adlData = data.downloads || []; + _adlRender(); + _adlUpdateBadge(); + } + } catch (e) { + console.error('Downloads page fetch error:', e); + } +} + +function _adlUpdateBadge() { + const activeCount = _adlData.filter(d => ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(d.status)).length; + _updateDlNavBadge(activeCount); +} + +function _updateDlNavBadge(count) { + const badge = document.getElementById('dl-nav-badge'); + if (badge) { + if (count > 0) { + badge.textContent = count; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + } +} + +function _adlRender() { + const list = document.getElementById('adl-list'); + const empty = document.getElementById('adl-empty'); + const countEl = document.getElementById('adl-count'); + if (!list) return; + + // Apply filter + const activeStatuses = ['downloading', 'searching', 'post_processing']; + const queuedStatuses = ['queued']; + const completedStatuses = ['completed', 'skipped', 'already_owned']; + const failedStatuses = ['failed', 'not_found', 'cancelled']; + + let filtered = _adlData; + if (_adlFilter === 'active') filtered = _adlData.filter(d => activeStatuses.includes(d.status)); + else if (_adlFilter === 'queued') filtered = _adlData.filter(d => queuedStatuses.includes(d.status)); + else if (_adlFilter === 'completed') filtered = _adlData.filter(d => completedStatuses.includes(d.status)); + else if (_adlFilter === 'failed') filtered = _adlData.filter(d => failedStatuses.includes(d.status)); + + const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length; + + if (countEl) { + const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length; + const queuedN = _adlData.filter(d => queuedStatuses.includes(d.status)).length; + const total = _adlData.length; + const parts = []; + if (activeN > 0) parts.push(`${activeN} active`); + if (queuedN > 0) parts.push(`${queuedN} queued`); + parts.push(`${total} total`); + countEl.textContent = parts.join(' / '); + } + + // Show/hide clear button + const clearBtn = document.getElementById('adl-clear-btn'); + if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none'; + + if (filtered.length === 0) { + if (empty) empty.style.display = ''; + // Clear any existing rows but keep the empty message + list.querySelectorAll('.adl-row').forEach(r => r.remove()); + return; + } + + if (empty) empty.style.display = 'none'; + + // Group by status category for section headers + const groups = { active: [], queued: [], completed: [], failed: [] }; + for (const dl of filtered) { + const cls = _adlStatusClass(dl.status); + if (cls === 'active') groups.active.push(dl); + else if (cls === 'queued') groups.queued.push(dl); + else if (cls === 'completed') groups.completed.push(dl); + else groups.failed.push(dl); + } + + let html = ''; + const sections = [ + { key: 'active', label: 'Active', items: groups.active }, + { key: 'queued', label: 'Queued', items: groups.queued }, + { key: 'completed', label: 'Completed', items: groups.completed }, + { key: 'failed', label: 'Failed', items: groups.failed }, + ]; + + for (const section of sections) { + if (section.items.length === 0) continue; + // Only show section headers in "all" filter mode + if (_adlFilter === 'all') { + html += `
${section.label} (${section.items.length})
`; + } + for (const dl of section.items) { + const statusClass = _adlStatusClass(dl.status); + const statusLabel = _adlStatusLabel(dl.status); + const title = _adlEsc(dl.title || 'Unknown Track'); + const artist = _adlEsc(dl.artist || ''); + const album = _adlEsc(dl.album || ''); + const batchName = _adlEsc(dl.batch_name || ''); + const error = dl.error ? _adlEsc(dl.error) : ''; + + const meta = [artist, album].filter(Boolean).join(' \u00B7 '); + const artHtml = dl.artwork + ? `` + : '
'; + + // Track position: "3 of 19" + const posText = dl.batch_total > 1 ? `${(dl.track_index || 0) + 1} of ${dl.batch_total}` : ''; + + html += `
+ ${artHtml} +
+
${title}
+ ${meta ? `
${meta}
` : ''} + ${batchName ? `
${batchName}${posText ? ' · Track ' + posText : ''}
` : ''} + ${error ? `
${error}
` : ''} +
+
+ + ${statusLabel} +
+
`; + } + } + + // Preserve empty element, inject rows + const emptyEl = document.getElementById('adl-empty'); + const emptyHtml = emptyEl ? emptyEl.outerHTML : ''; + list.innerHTML = emptyHtml + html; + const newEmpty = document.getElementById('adl-empty'); + if (newEmpty) newEmpty.style.display = filtered.length > 0 ? 'none' : ''; +} + +function _adlStatusClass(status) { + switch (status) { + case 'downloading': case 'searching': case 'post_processing': return 'active'; + case 'queued': case 'pending': return 'queued'; + case 'completed': case 'skipped': case 'already_owned': return 'completed'; + case 'failed': case 'not_found': return 'failed'; + case 'cancelled': return 'cancelled'; + default: return 'queued'; + } +} + +function _adlStatusLabel(status) { + switch (status) { + case 'downloading': return 'Downloading'; + case 'searching': return 'Searching'; + case 'post_processing': return 'Processing'; + case 'queued': case 'pending': return 'Queued'; + case 'completed': return 'Completed'; + case 'skipped': return 'Skipped'; + case 'already_owned': return 'Owned'; + case 'failed': return 'Failed'; + case 'not_found': return 'Not Found'; + case 'cancelled': return 'Cancelled'; + default: return status; + } +} + +function _adlEsc(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +async function adlClearCompleted() { + try { + const resp = await fetch('/api/downloads/clear-completed', { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + if (typeof showToast === 'function') showToast(`Cleared ${data.cleared} downloads`, 'success'); + _adlFetch(); + } + } catch (e) { + console.error('Error clearing completed downloads:', e); + } +} + +window.adlSetFilter = adlSetFilter; +window.adlClearCompleted = adlClearCompleted; diff --git a/webui/static/style.css b/webui/static/style.css index 14d45f8d..c5e0d300 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -55178,3 +55178,339 @@ body.reduce-effects *::after { -webkit-backdrop-filter: none !important; box-shadow: none !important; } + +/* ============================================ + ACTIVE DOWNLOADS PAGE — Premium Glassmorphic + ============================================ */ + +.adl-container { + padding: 28px 32px; + max-width: 960px; +} + +.adl-header { + margin-bottom: 24px; +} + +.adl-title { + font-size: 1.5rem; + font-weight: 700; + color: #fff; + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + letter-spacing: -0.02em; +} + +.adl-title svg { + color: rgb(var(--accent-rgb)); + filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.3)); +} + +.adl-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.adl-filter-pills { + display: flex; + gap: 4px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + padding: 3px; +} + +.adl-pill { + padding: 6px 16px; + border-radius: 8px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.45); + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.adl-pill:hover { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); +} + +.adl-pill.active { + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-light-rgb)); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.15); +} + +.adl-count { + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.3); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.adl-clear-btn { + padding: 5px 12px; + border-radius: 7px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + color: rgba(255, 255, 255, 0.4); + font-size: 0.72rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.adl-clear-btn:hover { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.7); + border-color: rgba(255, 255, 255, 0.12); +} + +.adl-list { + display: flex; + flex-direction: column; + gap: 3px; +} + +.adl-empty { + text-align: center; + padding: 64px 20px; + color: rgba(255, 255, 255, 0.2); + font-size: 0.9rem; +} + +/* ---- Section Headers ---- */ + +.adl-section-header { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.2); + padding: 12px 14px 4px; +} + +/* ---- Download Row ---- */ + +.adl-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.018); + border: 1px solid rgba(255, 255, 255, 0.035); + border-radius: 10px; + transition: all 0.2s ease; +} + +.adl-row:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.07); +} + +/* Active row accent glow */ +.adl-row.adl-row-active { + border-color: rgba(var(--accent-rgb), 0.18); + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.04) 0%, rgba(var(--accent-rgb), 0.01) 100%); + box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.06); +} + +/* Completed row subtle green */ +.adl-row.adl-row-completed { + border-color: rgba(34, 197, 94, 0.1); +} + +/* Failed row subtle red */ +.adl-row.adl-row-failed { + border-color: rgba(239, 68, 68, 0.1); +} + +.adl-row-art { + width: 44px; + height: 44px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.05); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.adl-row-art-empty { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%); +} + +.adl-row-info { + flex: 1; + min-width: 0; +} + +.adl-row-title { + font-size: 0.88rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.adl-row-meta { + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.adl-row-batch { + font-size: 0.7rem; + color: rgba(var(--accent-rgb), 0.55); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.adl-row-error { + font-size: 0.7rem; + color: rgba(239, 68, 68, 0.65); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---- Status Badges ---- */ + +.adl-row-status { + font-size: 0.76rem; + font-weight: 500; + white-space: nowrap; + min-width: 95px; + text-align: right; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +.adl-row-status.active { + color: rgb(var(--accent-light-rgb)); +} + +.adl-row-status.queued { + color: rgba(255, 255, 255, 0.3); +} + +.adl-row-status.completed { + color: #22c55e; +} + +.adl-row-status.failed { + color: #ef4444; +} + +.adl-row-status.cancelled { + color: rgba(255, 255, 255, 0.2); + text-decoration: line-through; + text-decoration-color: rgba(255, 255, 255, 0.1); +} + +/* Status dot indicator */ +.adl-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.adl-status-dot.active { + background: rgb(var(--accent-rgb)); + box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5); + animation: adlPulse 1.5s ease infinite; +} + +.adl-status-dot.queued { background: rgba(255, 255, 255, 0.2); } +.adl-status-dot.completed { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.3); } +.adl-status-dot.failed { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.3); } +.adl-status-dot.cancelled { background: rgba(255, 255, 255, 0.15); } + +@keyframes adlPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ---- Spinner ---- */ + +.adl-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 1.5px solid rgba(var(--accent-rgb), 0.2); + border-top-color: rgb(var(--accent-rgb)); + border-radius: 50%; + animation: adlSpin 0.6s linear infinite; + vertical-align: middle; +} + +@keyframes adlSpin { + to { transform: rotate(360deg); } +} + +/* ---- Nav Badge ---- */ + +.dl-nav-badge { + position: absolute; + top: 4px; + right: 8px; + background: rgb(var(--accent-rgb)); + color: #fff; + font-size: 0.6rem; + font-weight: 700; + min-width: 16px; + height: 16px; + line-height: 16px; + border-radius: 8px; + text-align: center; + padding: 0 4px; + box-shadow: 0 2px 6px rgba(var(--accent-rgb), 0.3); +} + +/* ---- Responsive ---- */ + +@media (max-width: 600px) { + .adl-container { + padding: 16px 12px; + } + + .adl-controls { + flex-direction: column; + align-items: flex-start; + } + + .adl-filter-pills { + flex-wrap: wrap; + } + + .adl-row-art { + width: 36px; + height: 36px; + border-radius: 6px; + } + + .adl-row-status { + min-width: 70px; + } +}