From 9898bd11906e833ba7dba31187415a346a6c1949 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:18:00 -0700 Subject: [PATCH] Add batch context panel to Downloads page Split Downloads page into main list (left) and batch panel (right). Each active batch gets a color-coded card with artwork thumbnail, progress bar, per-track status with download percentages, and expandable track list. Download rows get matching color indicators. - Click batch name to open its download/wishlist modal - Filter icon narrows main list to one batch with clear banner - Collapsible panel toggle for full-width list view - Completed batches fade out after 15 seconds - 7-day batch history with source type color dots - Artwork fallback shows colored initial when no art available - Per-track progress: download %, spinner for searching, proc label - source_page column on sync_history for UI origin tracking - /api/downloads/all includes batch summaries and per-track progress - /api/downloads/batch-history endpoint for history queries - Responsive layout, overflow-x hidden to prevent scroll flicker --- database/music_database.py | 38 +++- web_server.py | 76 ++++++- webui/index.html | 56 +++-- webui/static/helper.js | 1 + webui/static/script.js | 386 ++++++++++++++++++++++++++++++- webui/static/style.css | 454 ++++++++++++++++++++++++++++++++++++- 6 files changed, 976 insertions(+), 35 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 9cd3ca67..d7ad5e64 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -645,6 +645,16 @@ class MusicDatabase: except Exception: pass + # Migration: add source_page column to sync_history (UI origin context for batch panel) + try: + cursor.execute("SELECT source_page FROM sync_history LIMIT 1") + except Exception: + try: + cursor.execute("ALTER TABLE sync_history ADD COLUMN source_page TEXT") + logger.info("Added source_page column to sync_history table") + except Exception: + pass + # Migration: add track_artist column for per-track artist on compilations/DJ mixes try: cursor.execute("SELECT track_artist FROM tracks LIMIT 1") @@ -10202,7 +10212,7 @@ class MusicDatabase: def add_sync_history_entry(self, batch_id, playlist_id, playlist_name, source, sync_type, tracks_json, artist_context=None, album_context=None, thumb_url=None, total_tracks=0, is_album_download=False, - playlist_folder_mode=False): + playlist_folder_mode=False, source_page=None): """Record a new sync operation to sync_history.""" try: conn = self._get_connection() @@ -10210,11 +10220,11 @@ class MusicDatabase: cursor.execute(""" INSERT INTO sync_history (batch_id, playlist_id, playlist_name, source, sync_type, tracks_json, artist_context, album_context, thumb_url, total_tracks, - is_album_download, playlist_folder_mode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + is_album_download, playlist_folder_mode, source_page) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (batch_id, playlist_id, playlist_name, source, sync_type, tracks_json, artist_context, album_context, thumb_url, total_tracks, - int(is_album_download), int(playlist_folder_mode))) + int(is_album_download), int(playlist_folder_mode), source_page)) conn.commit() # Cap at 100 entries cursor.execute(""" @@ -10364,6 +10374,26 @@ class MusicDatabase: logger.debug(f"Error getting sync history stats: {e}") return {} + def get_recent_batch_history(self, days: int = 7, limit: int = 50) -> List[Dict[str, Any]]: + """Get completed batch history from the last N days for the downloads batch panel.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, batch_id, playlist_name, source, sync_type, source_page, + total_tracks, tracks_found, tracks_downloaded, tracks_failed, + thumb_url, is_album_download, started_at, completed_at + FROM sync_history + WHERE completed_at IS NOT NULL + AND started_at >= datetime('now', ? || ' days') + ORDER BY started_at DESC + LIMIT ? + """, (f'-{days}', limit)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting recent batch history: {e}") + return [] + def api_get_recently_added(self, entity_type: str = "albums", limit: int = 50) -> List[Dict[str, Any]]: """Get recently added entities, ordered by created_at DESC.""" table = {"artists": "artists", "albums": "albums", "tracks": "tracks"}.get(entity_type) diff --git a/web_server.py b/web_server.py index 585dcf4c..12d1e9cf 100644 --- a/web_server.py +++ b/web_server.py @@ -22313,6 +22313,7 @@ def get_version_info(): "• Reject Qobuz 30-second sample/preview downloads", "• Fix slskd timeout spam — dashboard and download status skip slskd polling when Soulseek is not active or disconnected", "• Fix Soulseek search queries missing album name — reduces wrong-artist downloads", + "• Downloads batch panel — color-coded batch cards with progress, cancel, expand, and 7-day history", ], }, { @@ -30282,6 +30283,22 @@ def get_all_downloads_unified(): artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0]) status = task.get('status', 'queued') + # Determine download progress percentage + progress = 0 + if status == 'completed': + progress = 100 + elif status == 'post_processing': + progress = 95 + elif status in ('downloading', 'searching'): + # Check live transfer data for real progress + task_filename = task.get('filename') or track_info.get('filename') + task_username = task.get('username') or track_info.get('username') + if task_filename and task_username: + lookup_key = _make_context_key(task_username, task_filename) + live_info = get_cached_transfer_data().get(lookup_key) + if live_info: + progress = live_info.get('percentComplete', 0) + items.append({ 'task_id': task_id, 'title': title, @@ -30289,6 +30306,7 @@ def get_all_downloads_unified(): 'album': album, 'artwork': artwork, 'status': status, + 'progress': progress, 'error': task.get('error_message'), 'batch_id': batch_id, 'batch_name': batch.get('playlist_name') or batch.get('album_name') or '', @@ -30302,10 +30320,30 @@ 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'])) + # Build batch summaries for the batch context panel + batch_summaries = [] + with tasks_lock: + for bid, batch in download_batches.items(): + queue = batch.get('queue', []) + statuses = [download_tasks[tid]['status'] for tid in queue if tid in download_tasks] + batch_summaries.append({ + 'batch_id': bid, + 'playlist_id': batch.get('playlist_id', ''), + 'batch_name': batch.get('playlist_name') or batch.get('album_name') or '', + 'source_page': batch.get('source_page') or batch.get('initiated_from') or '', + 'phase': batch.get('phase', 'unknown'), + 'total': len(queue), + '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')), + 'queued': sum(1 for s in statuses if s in ('queued', 'pending')), + }) + return jsonify({ 'success': True, 'downloads': items[:limit], 'total': len(items), + 'batches': batch_summaries, 'timestamp': time.time(), }) except Exception as e: @@ -30313,6 +30351,20 @@ def get_all_downloads_unified(): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/downloads/batch-history', methods=['GET']) +def get_batch_history(): + """Return completed batch summaries from the last N days for the batch panel history section.""" + try: + days = int(request.args.get('days', 7)) + limit = int(request.args.get('limit', 50)) + database = get_database() + history = database.get_recent_batch_history(days=days, limit=limit) + return jsonify({'success': True, 'history': history}) + except Exception as e: + logger.error(f"Error getting batch history: {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.""" @@ -31248,7 +31300,7 @@ def _detect_sync_source(playlist_id): def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, is_album_download, album_context, artist_context, - playlist_folder_mode): + playlist_folder_mode, source_page=None): """Record a sync start to the database. If a previous sync history entry exists for the same playlist_id, update it instead of creating a duplicate.""" @@ -31289,7 +31341,7 @@ def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, SET batch_id = ?, playlist_name = ?, source = ?, sync_type = ?, tracks_json = ?, artist_context = ?, album_context = ?, thumb_url = ?, total_tracks = ?, is_album_download = ?, - playlist_folder_mode = ?, started_at = CURRENT_TIMESTAMP, + playlist_folder_mode = ?, source_page = ?, started_at = CURRENT_TIMESTAMP, completed_at = NULL, tracks_found = 0, tracks_downloaded = 0, tracks_failed = 0 WHERE id = ? """, (batch_id, playlist_name, source, sync_type, @@ -31297,7 +31349,7 @@ def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, json.dumps(artist_context, ensure_ascii=False) if artist_context else None, json.dumps(album_context, ensure_ascii=False) if album_context else None, thumb_url, len(tracks), int(is_album_download), int(playlist_folder_mode), - existing['id'])) + source_page, existing['id'])) conn.commit() logger.info(f"Updated existing sync history entry {existing['id']} for '{playlist_name}'") return @@ -31316,7 +31368,8 @@ def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, thumb_url=thumb_url, total_tracks=len(tracks), is_album_download=is_album_download, - playlist_folder_mode=playlist_folder_mode + playlist_folder_mode=playlist_folder_mode, + source_page=source_page ) except Exception as e: logger.warning(f"Failed to record sync history start: {e}") @@ -32031,10 +32084,18 @@ def start_missing_tracks_process(playlist_id): 'wing_it': wing_it, } - # Record sync history + # Record sync history — derive source_page from context + if playlist_id == 'wishlist': + _source_page = 'wishlist' + elif is_album_download: + _source_page = 'album' + elif playlist_id.startswith('youtube_'): + _source_page = 'sync' + else: + _source_page = 'sync' _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks, is_album_download, album_context, artist_context, - playlist_folder_mode) + playlist_folder_mode, source_page=_source_page) # Link YouTube playlist to download process if this is a YouTube playlist if playlist_id.startswith('youtube_'): @@ -37854,7 +37915,8 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, is_album_download=False, album_context=None, artist_context=None, - playlist_folder_mode=False + playlist_folder_mode=False, + source_page='sync' ) try: diff --git a/webui/index.html b/webui/index.html index c92051d7..8f03b600 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2339,25 +2339,51 @@
-
-
-

Downloads

-
-
- - - - - +
+ +
+
+
+

Downloads

+
+
+ + + + + +
+
+ + +
+
-
- - +
+
No downloads yet. Start one from Search, Sync, Discover, or Artists.
-
-
No downloads yet. Start one from Search, Sync, Discover, or Artists.
+ +
+
+

Batches

+ +
+
+ +
+
diff --git a/webui/static/helper.js b/webui/static/helper.js index 0633e059..96a4ebb5 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3614,6 +3614,7 @@ const WHATS_NEW = { { title: 'Fix Wishlist Album Remove', desc: 'Removing albums from the Wishlist Nebula now works — API accepts album_name as fallback when album_id is unavailable' }, { title: 'Fix Soulseek Timeout Spam', desc: 'Dashboard stats and download status endpoints no longer poll slskd when Soulseek is not the active download source or is known to be disconnected. Eliminates connection timeout errors every 10 seconds for users who have a slskd URL configured but use YouTube/Tidal/etc.' }, { title: 'Fix Soulseek Search Missing Album Name', desc: 'Soulseek search queries now include the album name (Artist + Album + Track) as the first search attempt for all download sources. Previously this was excluded for Soulseek-only mode, causing wrong-artist downloads when an artist name matched an album folder in another user\'s library' }, + { title: 'Downloads Batch Panel', desc: 'Downloads page now shows a batch context panel on the right side. Each active batch (wishlist, sync, album download) gets a color-coded card with progress, cancel button, and expandable track list. Color indicators on download rows link them to their batch. Completed batch history shows the last 7 days', page: 'active-downloads' }, // --- April 15, 2026 --- { date: 'April 15, 2026' }, diff --git a/webui/static/script.js b/webui/static/script.js index 03583af5..ec8c759f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -75294,15 +75294,40 @@ function _syncDetailFilter(btn, filter) { let _adlPoller = null; let _adlFilter = 'all'; let _adlData = []; +let _adlBatches = []; +let _adlBatchHistory = []; +let _adlExpandedBatches = new Set(); +let _adlBatchHistoryPoller = null; +let _adlFilterBatchId = null; // When set, main list shows only this batch +const _batchColorMap = {}; +const _batchCompletedAt = {}; // batch_id -> timestamp when first seen as complete +let _batchColorNext = 0; + +function _getBatchColor(batchId) { + if (!batchId) return -1; + if (_batchColorMap[batchId] === undefined) { + // Deterministic color from batch_id hash for consistency across reloads + let hash = 0; + for (let i = 0; i < batchId.length; i++) hash = ((hash << 5) - hash + batchId.charCodeAt(i)) | 0; + _batchColorMap[batchId] = Math.abs(hash) % 8; + } + return _batchColorMap[batchId]; +} function loadActiveDownloadsPage() { _adlFetch(); - // Poll every 2 seconds while on this page + _adlFetchBatchHistory(); + // Poll downloads every 2 seconds, history every 60 seconds if (_adlPoller) clearInterval(_adlPoller); _adlPoller = setInterval(() => { if (currentPage === 'active-downloads') _adlFetch(); else { clearInterval(_adlPoller); _adlPoller = null; } }, 2000); + if (_adlBatchHistoryPoller) clearInterval(_adlBatchHistoryPoller); + _adlBatchHistoryPoller = setInterval(() => { + if (currentPage === 'active-downloads') _adlFetchBatchHistory(); + else { clearInterval(_adlBatchHistoryPoller); _adlBatchHistoryPoller = null; } + }, 60000); } function adlSetFilter(filter) { @@ -75317,7 +75342,9 @@ async function _adlFetch() { const data = await resp.json(); if (data.success) { _adlData = data.downloads || []; + _adlBatches = data.batches || []; _adlRender(); + _adlRenderBatchPanel(); _adlUpdateBadge(); } } catch (e) { @@ -75355,10 +75382,16 @@ function _adlRender() { 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)); + + // Batch filter: if a batch card is selected, narrow to that batch first + if (_adlFilterBatchId) { + filtered = filtered.filter(d => d.batch_id === _adlFilterBatchId); + } + + if (_adlFilter === 'active') filtered = filtered.filter(d => activeStatuses.includes(d.status)); + else if (_adlFilter === 'queued') filtered = filtered.filter(d => queuedStatuses.includes(d.status)); + else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.includes(d.status)); + else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status)); const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length; @@ -75377,6 +75410,25 @@ function _adlRender() { const clearBtn = document.getElementById('adl-clear-btn'); if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none'; + // Batch filter indicator banner + let existingBanner = document.getElementById('adl-batch-filter-banner'); + if (_adlFilterBatchId) { + const batchInfo = _adlBatches.find(b => b.batch_id === _adlFilterBatchId); + const batchName = batchInfo ? batchInfo.batch_name : 'Unknown batch'; + const colorIdx = _getBatchColor(_adlFilterBatchId); + const colorDot = colorIdx >= 0 ? `` : ''; + if (!existingBanner) { + existingBanner = document.createElement('div'); + existingBanner.id = 'adl-batch-filter-banner'; + existingBanner.className = 'adl-batch-filter-banner'; + list.parentNode.insertBefore(existingBanner, list); + } + existingBanner.innerHTML = `${colorDot}Showing: ${_adlEsc(batchName)} `; + existingBanner.style.display = ''; + } else if (existingBanner) { + existingBanner.style.display = 'none'; + } + if (filtered.length === 0) { if (empty) empty.style.display = ''; // Clear any existing rows but keep the empty message @@ -75427,7 +75479,13 @@ function _adlRender() { // Track position: "3 of 19" const posText = dl.batch_total > 1 ? `${(dl.track_index || 0) + 1} of ${dl.batch_total}` : ''; - html += `
+ const colorIdx = _getBatchColor(dl.batch_id); + const colorBar = colorIdx >= 0 + ? `
` + : ''; + + html += `
+ ${colorBar} ${artHtml}
${title}
@@ -75496,5 +75554,321 @@ async function adlClearCompleted() { } } +// ---- Batch Context Panel ---- + +const _BATCH_FADE_SECONDS = 15; // Remove completed batches after this many seconds + +function _adlRenderBatchPanel() { + const container = document.getElementById('adl-batch-active'); + const headerTitle = document.querySelector('.adl-batch-panel-title'); + if (!container) return; + + const now = Date.now(); + + // Filter out batches that completed more than FADE seconds ago + const visibleBatches = _adlBatches.filter(batch => { + const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error'; + if (!isTerminal) { + delete _batchCompletedAt[batch.batch_id]; // Reset if it came back to life + return true; + } + if (!_batchCompletedAt[batch.batch_id]) { + _batchCompletedAt[batch.batch_id] = now; + } + const elapsed = (now - _batchCompletedAt[batch.batch_id]) / 1000; + return elapsed < _BATCH_FADE_SECONDS; + }); + + // Update header with count + if (headerTitle) { + const activeCount = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error').length; + headerTitle.textContent = activeCount > 0 ? `Batches (${activeCount})` : 'Batches'; + } + + if (visibleBatches.length === 0) { + container.innerHTML = `
+ +
No active batches
+
Start a download from Search, Sync, or Wishlist
+
`; + return; + } + + let html = ''; + for (const batch of visibleBatches) { + const colorIdx = _getBatchColor(batch.batch_id); + const colorStyle = colorIdx >= 0 ? `border-left-color: rgba(var(--batch-color-${colorIdx}), 0.6)` : ''; + const isExpanded = _adlExpandedBatches.has(batch.batch_id); + const isFiltered = _adlFilterBatchId === batch.batch_id; + const total = batch.total || 1; + const done = batch.completed + batch.failed; + const pct = Math.round((done / total) * 100); + const hasFailed = batch.failed > 0; + const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error'; + const isActive = batch.phase === 'downloading' && batch.active > 0; + + // Fade progress for completing batches + let fadeStyle = ''; + if (isTerminal && _batchCompletedAt[batch.batch_id]) { + const elapsed = (now - _batchCompletedAt[batch.batch_id]) / 1000; + const fadeStart = _BATCH_FADE_SECONDS * 0.6; + if (elapsed > fadeStart) { + const fadeProgress = Math.min(1, (elapsed - fadeStart) / (_BATCH_FADE_SECONDS - fadeStart)); + fadeStyle = `opacity: ${1 - fadeProgress};`; + } + } + + const sourceBadge = batch.source_page + ? `${_adlEsc(batch.source_page)}` + : ''; + + // Phase label with icon + let phaseText = ''; + let phaseIcon = ''; + if (batch.phase === 'analysis') { + phaseText = 'Analyzing...'; + phaseIcon = ''; + } else if (batch.phase === 'downloading') { + phaseText = `${batch.completed}/${total} tracks`; + if (batch.active > 0) phaseIcon = ''; + } else if (batch.phase === 'complete') { + phaseText = `Done \u2014 ${batch.completed} tracks`; + phaseIcon = '\u2713'; + } else if (batch.phase === 'cancelled') { + phaseText = 'Cancelled'; + } else if (batch.phase === 'error') { + phaseText = 'Error'; + } else { + phaseText = batch.phase; + } + + // Get first track artwork for batch thumbnail, fallback to initial + const batchTracks = _adlData.filter(d => d.batch_id === batch.batch_id); + const artworkTrack = batchTracks.find(t => t.artwork); + let thumbHtml; + if (artworkTrack) { + thumbHtml = ``; + } else { + const initial = (batch.batch_name || 'D')[0].toUpperCase(); + const bgColor = colorIdx >= 0 ? `rgba(var(--batch-color-${colorIdx}), 0.15)` : 'rgba(255,255,255,0.05)'; + const fgColor = colorIdx >= 0 ? `rgba(var(--batch-color-${colorIdx}), 0.7)` : 'rgba(255,255,255,0.4)'; + thumbHtml = `
${initial}
`; + } + + // Build expanded tracks list with per-track progress + let tracksHtml = ''; + if (isExpanded) { + if (batchTracks.length > 0) { + tracksHtml = batchTracks.map(t => { + const cls = _adlStatusClass(t.status); + const progress = t.progress || 0; + + // Status indicator with detail + let statusHtml = ''; + if (t.status === 'downloading' && progress > 0) { + statusHtml = `${Math.round(progress)}%`; + } else if (t.status === 'searching') { + statusHtml = ``; + } else if (t.status === 'post_processing') { + statusHtml = `proc`; + } else if (cls === 'completed') { + statusHtml = `\u2713`; + } else if (cls === 'failed') { + statusHtml = `\u2717`; + } else { + statusHtml = `\u00B7`; + } + + // Mini progress bar for downloading tracks + const miniBar = t.status === 'downloading' && progress > 0 + ? `
` + : ''; + + return `
+ ${_adlEsc(t.title || 'Unknown')} + ${statusHtml} + ${miniBar} +
`; + }).join(''); + } else { + tracksHtml = '
No tracks loaded
'; + } + } + + const cardClasses = ['adl-batch-card']; + if (isExpanded) cardClasses.push('expanded'); + if (isActive) cardClasses.push('active-glow'); + if (isFiltered) cardClasses.push('filtered'); + + const playlistId = _adlEsc(batch.playlist_id || ''); + + html += `
+
+ ${thumbHtml} +
+ +
${phaseIcon}${phaseText}
+
+ ${sourceBadge} +
+ + ${!isTerminal ? `` : ''} +
+
+
+
+
+
${tracksHtml}
+
`; + } + + container.innerHTML = html; +} + +function _adlToggleBatch(batchId) { + if (_adlExpandedBatches.has(batchId)) { + _adlExpandedBatches.delete(batchId); + } else { + _adlExpandedBatches.add(batchId); + } + _adlRenderBatchPanel(); +} + +function _adlOpenBatchModal(batchId, playlistId, batchName) { + // For wishlist batches, navigate to wishlist and show modal + if (playlistId === 'wishlist') { + const clientProcess = activeDownloadProcesses['wishlist']; + if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) { + clientProcess.modalElement.style.display = 'flex'; + if (typeof WishlistModalState !== 'undefined') WishlistModalState.setVisible(); + } else { + rehydrateModal({ playlist_id: playlistId, playlist_name: batchName, batch_id: batchId }, true); + } + return; + } + + // For other batches, try to show existing modal or rehydrate + for (const [pid, process] of Object.entries(activeDownloadProcesses)) { + if (process.batchId === batchId && process.modalElement && document.body.contains(process.modalElement)) { + process.modalElement.style.display = 'flex'; + return; + } + } + // Rehydrate from server + rehydrateModal({ playlist_id: playlistId, playlist_name: batchName, batch_id: batchId }, true); +} + +function _adlFilterByBatch(batchId) { + if (_adlFilterBatchId === batchId) { + _adlFilterBatchId = null; // Toggle off + } else { + _adlFilterBatchId = batchId; + } + _adlRender(); + _adlRenderBatchPanel(); +} + +async function _adlCancelBatch(batchId) { + if (!confirm('Cancel this batch? Active downloads will be stopped.')) return; + try { + const resp = await fetch(`/api/playlists/${batchId}/cancel_batch`, { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + showToast(`Cancelled ${data.cancelled_tasks} downloads`, 'info'); + _adlFetch(); + } else { + showToast(data.error || 'Failed to cancel batch', 'error'); + } + } catch (e) { + showToast('Failed to cancel batch', 'error'); + } +} + +// ---- Batch History ---- + +async function _adlFetchBatchHistory() { + try { + const resp = await fetch('/api/downloads/batch-history?days=7&limit=50'); + const data = await resp.json(); + if (data.success) { + _adlBatchHistory = data.history || []; + _adlRenderBatchHistory(); + } + } catch (e) { + console.debug('Batch history fetch error:', e); + } +} + +function _adlRenderBatchHistory() { + const section = document.getElementById('adl-batch-history-section'); + const list = document.getElementById('adl-batch-history-list'); + if (!section || !list) return; + + if (_adlBatchHistory.length === 0) { + section.style.display = 'none'; + return; + } + + section.style.display = ''; + + list.innerHTML = _adlBatchHistory.map(h => { + const name = _adlEsc(h.playlist_name || 'Unknown'); + const downloaded = h.tracks_downloaded || 0; + const failed = h.tracks_failed || 0; + const total = h.total_tracks || 0; + const statsParts = [`${downloaded}/${total}`]; + if (failed > 0) statsParts.push(`${failed} failed`); + + let dateText = ''; + if (h.completed_at) { + try { + const d = new Date(h.completed_at); + const now = new Date(); + const diffMs = now - d; + const diffH = Math.floor(diffMs / 3600000); + if (diffH < 1) dateText = 'just now'; + else if (diffH < 24) dateText = `${diffH}h ago`; + else dateText = `${Math.floor(diffH / 24)}d ago`; + } catch (e) { + dateText = ''; + } + } + + const sourceLabel = h.source_page ? `${_adlEsc(h.source_page)}` : ''; + + // 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'; + const histDot = ``; + + return `
+ ${histDot} +
${name} ${sourceLabel}
+
${statsParts.join(' ')}
+
${dateText}
+
`; + }).join(''); +} + +function adlToggleBatchHistory() { + const section = document.getElementById('adl-batch-history-section'); + if (section) section.classList.toggle('expanded'); +} + +function adlToggleBatchPanel() { + const panel = document.getElementById('adl-batch-panel'); + if (panel) panel.classList.toggle('collapsed'); +} + window.adlSetFilter = adlSetFilter; window.adlClearCompleted = adlClearCompleted; +window._adlToggleBatch = _adlToggleBatch; +window._adlOpenBatchModal = _adlOpenBatchModal; +window._adlFilterByBatch = _adlFilterByBatch; +window._adlCancelBatch = _adlCancelBatch; +window.adlToggleBatchHistory = adlToggleBatchHistory; +window.adlToggleBatchPanel = adlToggleBatchPanel; diff --git a/webui/static/style.css b/webui/static/style.css index 005cadf5..f4bf17ca 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -55776,9 +55776,32 @@ body.reduce-effects *::after { ACTIVE DOWNLOADS PAGE — Premium Glassmorphic ============================================ */ -.adl-container { +/* Batch color palette */ +:root { + --batch-color-0: 139, 92, 246; /* violet */ + --batch-color-1: 59, 130, 246; /* blue */ + --batch-color-2: 236, 72, 153; /* pink */ + --batch-color-3: 245, 158, 11; /* amber */ + --batch-color-4: 16, 185, 129; /* emerald */ + --batch-color-5: 239, 68, 68; /* red */ + --batch-color-6: 6, 182, 212; /* cyan */ + --batch-color-7: 168, 85, 247; /* purple */ +} + +.adl-layout { + display: flex; + gap: 0; padding: 28px 32px; - max-width: 960px; + max-width: 1440px; +} + +.adl-main { + flex: 1; + min-width: 0; +} + +.adl-container { + padding: 0; } .adl-header { @@ -56081,10 +56104,435 @@ body.reduce-effects *::after { box-shadow: 0 2px 6px rgba(var(--accent-rgb), 0.3); } +/* ---- Batch row color indicator ---- */ +.adl-row-batch-color { + width: 3px; + border-radius: 2px; + align-self: stretch; + flex-shrink: 0; + min-height: 32px; +} + +/* ---- Batch Context Panel ---- */ +.adl-batch-panel { + width: 340px; + flex-shrink: 0; + border-left: 1px solid rgba(255, 255, 255, 0.06); + padding: 0 12px 0 24px; + margin-left: 8px; + overflow-y: auto; + overflow-x: hidden; + max-height: calc(100vh - 120px); + position: sticky; + top: 80px; +} + +.adl-batch-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} + +.adl-batch-panel-title { + font-size: 1rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); + margin: 0; +} + +/* Collapse toggle */ +.adl-batch-panel-collapse { + background: none; + border: none; + color: rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s; +} + +.adl-batch-panel-collapse:hover { + color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.05); +} + +.adl-batch-panel-collapse svg { + transition: transform 0.2s; +} + +.adl-batch-panel.collapsed .adl-batch-panel-collapse svg { + transform: rotate(180deg); +} + +.adl-batch-panel.collapsed .adl-batch-active, +.adl-batch-panel.collapsed .adl-batch-history-section { + display: none; +} + +.adl-batch-panel.collapsed { + width: 44px; + min-width: 44px; + padding-left: 10px; +} + +.adl-batch-empty { + color: rgba(255, 255, 255, 0.3); + font-size: 0.8rem; + text-align: center; + padding: 32px 12px; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Batch card */ +.adl-batch-card { + background: rgba(255, 255, 255, 0.018); + border: 1px solid rgba(255, 255, 255, 0.035); + border-radius: 10px; + padding: 10px 12px; + margin-bottom: 6px; + cursor: pointer; + transition: all 0.2s ease; + border-left: 3px solid transparent; +} + +.adl-batch-card:hover { + background: rgba(255, 255, 255, 0.035); + border-color: rgba(255, 255, 255, 0.06); +} + +.adl-batch-card-top { + display: flex; + align-items: center; + gap: 10px; +} + +.adl-batch-card-info { + flex: 1; + min-width: 0; +} + +.adl-batch-card-name { + font-size: 0.82rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.adl-batch-card-meta { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.4); + margin-top: 2px; +} + +.adl-batch-card-source { + font-size: 0.65rem; + padding: 1px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +/* Batch card thumbnail */ +.adl-batch-card-thumb { + width: 36px; + height: 36px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; +} + +.adl-batch-card-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.adl-batch-card-cancel, +.adl-batch-card-filter { + background: none; + border: none; + color: rgba(255, 255, 255, 0.3); + cursor: pointer; + padding: 3px; + border-radius: 4px; + transition: all 0.2s; +} + +.adl-batch-card-cancel:hover { + color: #ef4444; +} + +.adl-batch-card-filter:hover { + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.05); +} + +.adl-batch-card-filter.active { + color: rgba(var(--accent-rgb), 1); + background: rgba(var(--accent-rgb), 0.1); +} + +/* Active batch glow */ +.adl-batch-card.active-glow { + border-color: rgba(var(--accent-rgb), 0.15); + box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.06); +} + +/* Filtered batch highlight */ +.adl-batch-card.filtered { + background: rgba(var(--accent-rgb), 0.04); + border-color: rgba(var(--accent-rgb), 0.15); +} + +/* Progress bar inside batch card */ +.adl-batch-progress { + height: 3px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + margin-top: 8px; + overflow: hidden; +} + +.adl-batch-progress-fill { + height: 100%; + border-radius: 2px; + background: rgba(var(--accent-rgb), 0.7); + transition: width 0.4s ease; +} + +.adl-batch-progress-fill.has-failed { + background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.7), #ef4444); +} + +/* Expanded tracks list */ +.adl-batch-tracks { + display: none; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + max-height: 400px; + overflow-y: auto; + overflow-x: hidden; +} + +.adl-batch-card.expanded .adl-batch-tracks { + display: block; +} + +/* Clickable batch name */ +.adl-batch-card-link { + cursor: pointer; + transition: color 0.15s; +} + +.adl-batch-card-link:hover { + color: rgba(var(--accent-rgb), 1); + text-decoration: underline; + text-underline-offset: 2px; +} + +.adl-batch-track-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 0.75rem; + flex-wrap: wrap; +} + +.adl-batch-track-title { + flex: 1; + color: rgba(255, 255, 255, 0.6); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.adl-batch-track-status { + flex-shrink: 0; + font-size: 0.7rem; + min-width: 24px; + text-align: right; +} + +.adl-batch-track-status.completed { color: #22c55e; } +.adl-batch-track-status.active { color: rgba(var(--accent-rgb), 1); } +.adl-batch-track-status.failed { color: #ef4444; } +.adl-batch-track-status.queued { color: rgba(255, 255, 255, 0.3); } + +/* Mini progress bar per track */ +.adl-batch-track-progress { + width: 100%; + height: 2px; + background: rgba(255, 255, 255, 0.04); + border-radius: 1px; + overflow: hidden; + margin-top: -2px; +} + +.adl-batch-track-progress-fill { + height: 100%; + background: rgba(var(--accent-rgb), 0.5); + border-radius: 1px; + transition: width 0.4s ease; +} + +/* Thumbnail fallback (initial letter) */ +.adl-batch-card-thumb-fallback { + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + font-weight: 700; + border-radius: 6px; +} + +/* Batch filter banner above download list */ +.adl-batch-filter-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + margin-bottom: 8px; + background: rgba(var(--accent-rgb), 0.06); + border: 1px solid rgba(var(--accent-rgb), 0.12); + border-radius: 8px; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.7); +} + +.adl-filter-banner-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.adl-filter-banner-clear { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.6); + font-size: 0.72rem; + padding: 2px 10px; + border-radius: 4px; + cursor: pointer; + margin-left: auto; + transition: all 0.15s; +} + +.adl-filter-banner-clear:hover { + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.9); +} + +/* History source color dot */ +.adl-batch-history-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +/* Completed batch cards fade via inline opacity from JS */ +.adl-batch-card { + transition: all 0.2s ease, opacity 0.5s ease; +} + +/* Batch history section */ +.adl-batch-history-header { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + padding: 8px 0; + margin-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.4); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.3px; +} + +.adl-batch-history-header:hover { + color: rgba(255, 255, 255, 0.6); +} + +.adl-batch-history-chevron { + transition: transform 0.2s; +} + +.adl-batch-history-section.expanded .adl-batch-history-chevron { + transform: rotate(180deg); +} + +.adl-batch-history-list { + display: none; +} + +.adl-batch-history-section.expanded .adl-batch-history-list { + display: block; +} + +.adl-batch-history-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.02); +} + +.adl-batch-history-name { + flex: 1; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.adl-batch-history-stats { + font-size: 0.68rem; + color: rgba(255, 255, 255, 0.3); + flex-shrink: 0; +} + +.adl-batch-history-date { + font-size: 0.65rem; + color: rgba(255, 255, 255, 0.2); + flex-shrink: 0; +} + /* ---- Responsive ---- */ +@media (max-width: 900px) { + .adl-layout { + flex-direction: column; + padding: 20px 16px; + } + + .adl-batch-panel { + width: 100%; + border-left: none; + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding: 16px 12px 0 0; + margin-left: 0; + margin-top: 16px; + max-height: 300px; + position: static; + } +} + @media (max-width: 600px) { - .adl-container { + .adl-layout { padding: 16px 12px; }