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
pull/315/head
Broque Thomas 4 weeks ago
parent 5e62229d00
commit 9898bd1190

@ -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)

@ -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:

@ -2339,25 +2339,51 @@
<!-- Active Downloads Page -->
<div class="page" id="active-downloads-page">
<div class="adl-container">
<div class="adl-header">
<h2 class="adl-title"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Downloads</h2>
<div class="adl-controls">
<div class="adl-filter-pills" id="adl-filter-pills">
<button class="adl-pill active" data-filter="all" onclick="adlSetFilter('all')">All</button>
<button class="adl-pill" data-filter="active" onclick="adlSetFilter('active')">Active</button>
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
<button class="adl-pill" data-filter="completed" onclick="adlSetFilter('completed')">Completed</button>
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</button>
<div class="adl-layout">
<!-- Left: download list -->
<div class="adl-main">
<div class="adl-container">
<div class="adl-header">
<h2 class="adl-title"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Downloads</h2>
<div class="adl-controls">
<div class="adl-filter-pills" id="adl-filter-pills">
<button class="adl-pill active" data-filter="all" onclick="adlSetFilter('all')">All</button>
<button class="adl-pill" data-filter="active" onclick="adlSetFilter('active')">Active</button>
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
<button class="adl-pill" data-filter="completed" onclick="adlSetFilter('completed')">Completed</button>
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</button>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="adl-count" id="adl-count"></span>
<button class="adl-clear-btn" id="adl-clear-btn" onclick="adlClearCompleted()" style="display:none">Clear Completed</button>
</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="adl-count" id="adl-count"></span>
<button class="adl-clear-btn" id="adl-clear-btn" onclick="adlClearCompleted()" style="display:none">Clear Completed</button>
<div class="adl-list" id="adl-list">
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Artists.</div>
</div>
</div>
</div>
<div class="adl-list" id="adl-list">
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Artists.</div>
<!-- Right: batch context panel -->
<div class="adl-batch-panel" id="adl-batch-panel">
<div class="adl-batch-panel-header">
<h3 class="adl-batch-panel-title">Batches</h3>
<button class="adl-batch-panel-collapse" id="adl-batch-collapse" onclick="adlToggleBatchPanel()" title="Toggle batch panel">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="adl-batch-active" id="adl-batch-active">
<!-- Active batch cards rendered by JS -->
</div>
<div class="adl-batch-history-section" id="adl-batch-history-section" style="display:none">
<div class="adl-batch-history-header" onclick="adlToggleBatchHistory()">
<span>Recent History</span>
<svg class="adl-batch-history-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="adl-batch-history-list" id="adl-batch-history-list">
<!-- Completed batch history rendered by JS -->
</div>
</div>
</div>
</div>
</div>

@ -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' },

@ -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 ? `<span class="adl-filter-banner-dot" style="background:rgba(var(--batch-color-${colorIdx}),0.7)"></span>` : '';
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: <strong>${_adlEsc(batchName)}</strong> <button class="adl-filter-banner-clear" onclick="_adlFilterByBatch('${_adlFilterBatchId}')">Clear filter</button>`;
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 += `<div class="adl-row adl-row-${statusClass}" data-task-id="${dl.task_id}">
const colorIdx = _getBatchColor(dl.batch_id);
const colorBar = colorIdx >= 0
? `<div class="adl-row-batch-color" style="background:rgba(var(--batch-color-${colorIdx}),0.6)"></div>`
: '';
html += `<div class="adl-row adl-row-${statusClass}" data-task-id="${dl.task_id}" data-batch-id="${dl.batch_id || ''}">
${colorBar}
${artHtml}
<div class="adl-row-info">
<div class="adl-row-title">${title}</div>
@ -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 = `<div class="adl-batch-empty">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" style="opacity:0.25;margin-bottom:6px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<div>No active batches</div>
<div style="font-size:0.7rem;margin-top:2px;opacity:0.5">Start a download from Search, Sync, or Wishlist</div>
</div>`;
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
? `<span class="adl-batch-card-source">${_adlEsc(batch.source_page)}</span>`
: '';
// Phase label with icon
let phaseText = '';
let phaseIcon = '';
if (batch.phase === 'analysis') {
phaseText = 'Analyzing...';
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
} else if (batch.phase === 'downloading') {
phaseText = `${batch.completed}/${total} tracks`;
if (batch.active > 0) phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
} else if (batch.phase === 'complete') {
phaseText = `Done \u2014 ${batch.completed} tracks`;
phaseIcon = '<span style="color:#22c55e;margin-right:4px">\u2713</span>';
} 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 = `<img class="adl-batch-card-thumb" src="${_adlEsc(artworkTrack.artwork)}" alt="" onerror="this.outerHTML='<div class=\\'adl-batch-card-thumb adl-batch-card-thumb-fallback\\'>${_adlEsc((batch.batch_name || 'D')[0])}</div>'">`;
} 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 = `<div class="adl-batch-card-thumb adl-batch-card-thumb-fallback" style="background:${bgColor};color:${fgColor}">${initial}</div>`;
}
// 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 = `<span class="adl-batch-track-status active">${Math.round(progress)}%</span>`;
} else if (t.status === 'searching') {
statusHtml = `<span class="adl-batch-track-status active"><span class="adl-spinner" style="width:8px;height:8px"></span></span>`;
} else if (t.status === 'post_processing') {
statusHtml = `<span class="adl-batch-track-status active" title="Processing">proc</span>`;
} else if (cls === 'completed') {
statusHtml = `<span class="adl-batch-track-status completed">\u2713</span>`;
} else if (cls === 'failed') {
statusHtml = `<span class="adl-batch-track-status failed">\u2717</span>`;
} else {
statusHtml = `<span class="adl-batch-track-status queued">\u00B7</span>`;
}
// Mini progress bar for downloading tracks
const miniBar = t.status === 'downloading' && progress > 0
? `<div class="adl-batch-track-progress"><div class="adl-batch-track-progress-fill" style="width:${progress}%"></div></div>`
: '';
return `<div class="adl-batch-track-row">
<span class="adl-batch-track-title">${_adlEsc(t.title || 'Unknown')}</span>
${statusHtml}
${miniBar}
</div>`;
}).join('');
} else {
tracksHtml = '<div style="font-size:0.7rem;color:rgba(255,255,255,0.3);padding:4px 0">No tracks loaded</div>';
}
}
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 += `<div class="${cardClasses.join(' ')}" style="${colorStyle}${fadeStyle}" data-batch-id="${batch.batch_id}" onclick="_adlToggleBatch('${batch.batch_id}')">
<div class="adl-batch-card-top">
${thumbHtml}
<div class="adl-batch-card-info">
<div class="adl-batch-card-name adl-batch-card-link" onclick="event.stopPropagation(); _adlOpenBatchModal('${batch.batch_id}', '${playlistId}', '${_adlEsc(batch.batch_name || 'Download')}')" title="Open download modal">${_adlEsc(batch.batch_name || 'Download')}</div>
<div class="adl-batch-card-meta">${phaseIcon}${phaseText}</div>
</div>
${sourceBadge}
<div class="adl-batch-card-actions">
<button class="adl-batch-card-filter ${isFiltered ? 'active' : ''}" onclick="event.stopPropagation(); _adlFilterByBatch('${batch.batch_id}')" title="${isFiltered ? 'Show all downloads' : 'Filter to this batch'}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
${!isTerminal ? `<button class="adl-batch-card-cancel" onclick="event.stopPropagation(); _adlCancelBatch('${batch.batch_id}')" title="Cancel batch">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>` : ''}
</div>
</div>
<div class="adl-batch-progress">
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
</div>
<div class="adl-batch-tracks">${tracksHtml}</div>
</div>`;
}
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(`<span style="color:#ef4444">${failed} failed</span>`);
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 ? `<span class="adl-batch-card-source" style="font-size:0.6rem;padding:0 4px">${_adlEsc(h.source_page)}</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';
const histDot = `<span class="adl-batch-history-dot" style="background:rgba(${dotColor}, 0.6)"></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>
</div>`;
}).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;

@ -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;
}

Loading…
Cancel
Save