Add centralized Downloads page with live status across all sources
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.
This commit is contained in:
parent
ce8c3b9cbb
commit
7798f56885
4 changed files with 738 additions and 2 deletions
150
web_server.py
150
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):
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@
|
|||
<option value="discover">Discover</option>
|
||||
<option value="artists">Artists</option>
|
||||
<option value="automations">Automations</option>
|
||||
<option value="active-downloads">Downloads</option>
|
||||
<option value="library">Library</option>
|
||||
<option value="stats">Listening Stats</option>
|
||||
<option value="playlist-explorer">Playlist Explorer</option>
|
||||
|
|
@ -116,6 +117,7 @@
|
|||
<label><input type="checkbox" value="discover" checked> Discover</label>
|
||||
<label><input type="checkbox" value="artists" checked> Artists</label>
|
||||
<label><input type="checkbox" value="automations" checked> Automations</label>
|
||||
<label><input type="checkbox" value="active-downloads" checked> Downloads</label>
|
||||
<label><input type="checkbox" value="library" checked> Library</label>
|
||||
<label><input type="checkbox" value="stats" checked> Listening Stats</label>
|
||||
<label><input type="checkbox" value="playlist-explorer" checked> Playlist Explorer</label>
|
||||
|
|
@ -214,6 +216,11 @@
|
|||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
|
||||
<span class="nav-text">Automations</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="active-downloads">
|
||||
<span class="nav-icon"><svg class="nav-svg" 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></span>
|
||||
<span class="nav-text">Downloads</span>
|
||||
<span class="dl-nav-badge hidden" id="dl-nav-badge">0</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="library">
|
||||
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg></span>
|
||||
<span class="nav-text">Library</span>
|
||||
|
|
@ -2626,6 +2633,31 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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 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>
|
||||
|
||||
<!-- Library Page -->
|
||||
<div class="page" id="library-page">
|
||||
<div class="library-container">
|
||||
|
|
|
|||
|
|
@ -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 += `<div class="adl-section-header">${section.label} (${section.items.length})</div>`;
|
||||
}
|
||||
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
|
||||
? `<img class="adl-row-art" src="${_adlEsc(dl.artwork)}" alt="" onerror="this.style.display='none'">`
|
||||
: '<div class="adl-row-art adl-row-art-empty"></div>';
|
||||
|
||||
// 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}">
|
||||
${artHtml}
|
||||
<div class="adl-row-info">
|
||||
<div class="adl-row-title">${title}</div>
|
||||
${meta ? `<div class="adl-row-meta">${meta}</div>` : ''}
|
||||
${batchName ? `<div class="adl-row-batch">${batchName}${posText ? ' · Track ' + posText : ''}</div>` : ''}
|
||||
${error ? `<div class="adl-row-error">${error}</div>` : ''}
|
||||
</div>
|
||||
<div class="adl-row-status ${statusClass}">
|
||||
<span class="adl-status-dot ${statusClass}"></span>
|
||||
${statusLabel}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 '<span class="adl-spinner"></span>Downloading';
|
||||
case 'searching': return '<span class="adl-spinner"></span>Searching';
|
||||
case 'post_processing': return '<span class="adl-spinner"></span>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, '>').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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue