Merge origin/fix/downloads-page into dev
This commit is contained in:
commit
a0eb289d63
3 changed files with 193 additions and 23 deletions
111
web_server.py
111
web_server.py
|
|
@ -31570,13 +31570,26 @@ def get_all_downloads_unified():
|
||||||
album = str(raw_album) if raw_album 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 ''
|
artwork = track_info.get('artwork_url') or track_info.get('image_url') or track_info.get('album_art') or ''
|
||||||
# Try album images
|
# Try album images (both image_url and images[] formats)
|
||||||
if not artwork:
|
if not artwork:
|
||||||
raw_alb = track_info.get('album')
|
raw_alb = track_info.get('album')
|
||||||
if isinstance(raw_alb, dict):
|
if isinstance(raw_alb, dict):
|
||||||
images = raw_alb.get('images') or []
|
artwork = raw_alb.get('image_url') or ''
|
||||||
if images and isinstance(images, list) and len(images) > 0:
|
if not artwork:
|
||||||
artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
|
images = raw_alb.get('images') or []
|
||||||
|
if images and isinstance(images, list) and len(images) > 0:
|
||||||
|
artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
|
||||||
|
# Try spotify_data nested object (wishlist tracks store metadata here)
|
||||||
|
if not artwork:
|
||||||
|
sp_data = track_info.get('spotify_data') or {}
|
||||||
|
if isinstance(sp_data, dict):
|
||||||
|
sp_album = sp_data.get('album') or {}
|
||||||
|
if isinstance(sp_album, dict):
|
||||||
|
artwork = sp_album.get('image_url') or ''
|
||||||
|
if not artwork:
|
||||||
|
sp_imgs = sp_album.get('images') or []
|
||||||
|
if sp_imgs and isinstance(sp_imgs, list) and isinstance(sp_imgs[0], dict):
|
||||||
|
artwork = sp_imgs[0].get('url', '')
|
||||||
|
|
||||||
status = task.get('status', 'queued')
|
status = task.get('status', 'queued')
|
||||||
# Determine download progress percentage
|
# Determine download progress percentage
|
||||||
|
|
@ -31620,6 +31633,86 @@ def get_all_downloads_unified():
|
||||||
# Sort: active first (by priority), then by timestamp desc within each group
|
# Sort: active first (by priority), then by timestamp desc within each group
|
||||||
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
|
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
|
||||||
|
|
||||||
|
# POST-LOCK: Enrich missing artwork from metadata cache (handles legacy
|
||||||
|
# wishlist tracks that were stored without image data)
|
||||||
|
items_needing_art = [it for it in items if not it.get('artwork') and it.get('title')]
|
||||||
|
if items_needing_art:
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
with database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# 1. Batch lookup by track name
|
||||||
|
track_names = list({it['title'] for it in items_needing_art if it['title']})
|
||||||
|
name_to_art = {}
|
||||||
|
if track_names:
|
||||||
|
placeholders = ','.join('?' for _ in track_names)
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT name, image_url FROM metadata_cache_entities
|
||||||
|
WHERE entity_type='track' AND name IN ({placeholders})
|
||||||
|
AND image_url IS NOT NULL AND image_url != ''
|
||||||
|
""", track_names)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
if row[0] not in name_to_art:
|
||||||
|
name_to_art[row[0]] = row[1]
|
||||||
|
for it in items_needing_art:
|
||||||
|
cached_url = name_to_art.get(it['title'])
|
||||||
|
if cached_url:
|
||||||
|
it['artwork'] = cached_url
|
||||||
|
|
||||||
|
# 2. Remaining items: look up by album name (album art is better than nothing)
|
||||||
|
still_needing = [it for it in items_needing_art if not it.get('artwork') and it.get('album')]
|
||||||
|
if still_needing:
|
||||||
|
album_names = list({it['album'] for it in still_needing if it['album']})
|
||||||
|
if album_names:
|
||||||
|
placeholders = ','.join('?' for _ in album_names)
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT name, image_url FROM metadata_cache_entities
|
||||||
|
WHERE entity_type='album' AND name IN ({placeholders})
|
||||||
|
AND image_url IS NOT NULL AND image_url != ''
|
||||||
|
""", album_names)
|
||||||
|
album_to_art = {}
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
if row[0] not in album_to_art:
|
||||||
|
album_to_art[row[0]] = row[1]
|
||||||
|
for it in still_needing:
|
||||||
|
cached_url = album_to_art.get(it['album'])
|
||||||
|
if cached_url:
|
||||||
|
it['artwork'] = cached_url
|
||||||
|
|
||||||
|
# 3. Last resort: check matched_downloads_context for items with active downloads
|
||||||
|
final_needing = [it for it in items_needing_art if not it.get('artwork')]
|
||||||
|
if final_needing:
|
||||||
|
with matched_context_lock:
|
||||||
|
for it in final_needing:
|
||||||
|
# Find task in download_tasks to get username/filename for context lookup
|
||||||
|
task_id = it.get('task_id')
|
||||||
|
if not task_id:
|
||||||
|
continue
|
||||||
|
with tasks_lock:
|
||||||
|
task = download_tasks.get(task_id)
|
||||||
|
if not task:
|
||||||
|
continue
|
||||||
|
t_username = task.get('username')
|
||||||
|
t_filename = task.get('filename')
|
||||||
|
if t_username and t_filename:
|
||||||
|
ctx_key = _make_context_key(t_username, t_filename)
|
||||||
|
ctx = matched_downloads_context.get(ctx_key)
|
||||||
|
if ctx:
|
||||||
|
_sp_album = ctx.get('spotify_album') or {}
|
||||||
|
_sp_track = ctx.get('track_info') or {}
|
||||||
|
_sp_images = _sp_album.get('images') or []
|
||||||
|
_art = ''
|
||||||
|
if _sp_images and isinstance(_sp_images[0], dict):
|
||||||
|
_art = _sp_images[0].get('url', '') or ''
|
||||||
|
if not _art:
|
||||||
|
_art = _sp_album.get('image_url') or ''
|
||||||
|
if not _art:
|
||||||
|
_art = _sp_track.get('image_url') or ''
|
||||||
|
if _art:
|
||||||
|
it['artwork'] = _art
|
||||||
|
except Exception as cache_err:
|
||||||
|
logger.debug(f"Metadata cache artwork lookup failed: {cache_err}")
|
||||||
|
|
||||||
# Build batch summaries for the batch context panel
|
# Build batch summaries for the batch context panel
|
||||||
batch_summaries = []
|
batch_summaries = []
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
|
|
@ -31634,6 +31727,7 @@ def get_all_downloads_unified():
|
||||||
'phase': batch.get('phase', 'unknown'),
|
'phase': batch.get('phase', 'unknown'),
|
||||||
'total': len(queue),
|
'total': len(queue),
|
||||||
'analysis_total': batch.get('analysis_total', len(queue)),
|
'analysis_total': batch.get('analysis_total', len(queue)),
|
||||||
|
'analysis_processed': batch.get('analysis_processed', 0),
|
||||||
'completed': sum(1 for s in statuses if s in ('completed', 'skipped', 'already_owned')),
|
'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')),
|
'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')),
|
'active': sum(1 for s in statuses if s in ('downloading', 'searching', 'post_processing')),
|
||||||
|
|
@ -31680,9 +31774,16 @@ def clear_completed_downloads():
|
||||||
for tid in task_ids_to_remove:
|
for tid in task_ids_to_remove:
|
||||||
del download_tasks[tid]
|
del download_tasks[tid]
|
||||||
cleared += 1
|
cleared += 1
|
||||||
# Also clean up empty batches
|
# Also clean up empty batches (but not those still actively processing)
|
||||||
|
active_phases = {'analysis', 'downloading', 'queued'}
|
||||||
empty_batches = []
|
empty_batches = []
|
||||||
for bid, batch in download_batches.items():
|
for bid, batch in download_batches.items():
|
||||||
|
# Never remove batches that are still actively working
|
||||||
|
if batch.get('phase') in active_phases:
|
||||||
|
# Still prune cleared tasks from queue
|
||||||
|
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
|
||||||
|
batch['queue'] = remaining
|
||||||
|
continue
|
||||||
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
|
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
|
||||||
if not remaining:
|
if not remaining:
|
||||||
empty_batches.append(bid)
|
empty_batches.append(bid)
|
||||||
|
|
|
||||||
|
|
@ -2488,6 +2488,10 @@ function _adlRenderBatchPanel() {
|
||||||
const hasFailed = batch.failed > 0;
|
const hasFailed = batch.failed > 0;
|
||||||
const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error';
|
const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error';
|
||||||
const isActive = batch.phase === 'downloading' && batch.active > 0;
|
const isActive = batch.phase === 'downloading' && batch.active > 0;
|
||||||
|
const isAnalyzing = batch.phase === 'analysis';
|
||||||
|
const analysisTotal = batch.analysis_total || 0;
|
||||||
|
const analysisProcessed = batch.analysis_processed || 0;
|
||||||
|
const analysisPct = analysisTotal > 0 ? Math.round((analysisProcessed / analysisTotal) * 100) : 0;
|
||||||
|
|
||||||
// Fade progress for completing batches
|
// Fade progress for completing batches
|
||||||
let fadeStyle = '';
|
let fadeStyle = '';
|
||||||
|
|
@ -2511,7 +2515,7 @@ function _adlRenderBatchPanel() {
|
||||||
phaseText = 'Queued';
|
phaseText = 'Queued';
|
||||||
phaseIcon = '<span style="color:#eab308;margin-right:4px">⏳</span>';
|
phaseIcon = '<span style="color:#eab308;margin-right:4px">⏳</span>';
|
||||||
} else if (batch.phase === 'analysis') {
|
} else if (batch.phase === 'analysis') {
|
||||||
phaseText = 'Analyzing...';
|
phaseText = analysisTotal > 0 ? `Analyzing ${analysisProcessed}/${analysisTotal}...` : 'Analyzing...';
|
||||||
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
|
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
|
||||||
} else if (batch.phase === 'downloading') {
|
} else if (batch.phase === 'downloading') {
|
||||||
phaseText = `${batch.completed}/${total} tracks`;
|
phaseText = `${batch.completed}/${total} tracks`;
|
||||||
|
|
@ -2610,7 +2614,7 @@ function _adlRenderBatchPanel() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="adl-batch-progress">
|
<div class="adl-batch-progress">
|
||||||
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
|
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${isAnalyzing ? analysisPct : pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="adl-batch-tracks">${tracksHtml}</div>
|
<div class="adl-batch-tracks">${tracksHtml}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -2830,10 +2834,21 @@ function _adlRenderBatchHistory() {
|
||||||
list.innerHTML = _adlBatchHistory.map(h => {
|
list.innerHTML = _adlBatchHistory.map(h => {
|
||||||
const name = _adlEsc(h.playlist_name || 'Unknown');
|
const name = _adlEsc(h.playlist_name || 'Unknown');
|
||||||
const downloaded = h.tracks_downloaded || 0;
|
const downloaded = h.tracks_downloaded || 0;
|
||||||
|
const found = h.tracks_found || 0;
|
||||||
const failed = h.tracks_failed || 0;
|
const failed = h.tracks_failed || 0;
|
||||||
const total = h.total_tracks || 0;
|
const total = h.total_tracks || 0;
|
||||||
const statsParts = [`${downloaded}/${total}`];
|
|
||||||
if (failed > 0) statsParts.push(`<span style="color:#ef4444">${failed} failed</span>`);
|
// Build stats line: "X owned · X new · X failed · X missing"
|
||||||
|
const statsParts = [];
|
||||||
|
if (found > 0 || downloaded > 0) {
|
||||||
|
const owned = found - downloaded; // already in library before this sync
|
||||||
|
if (owned > 0) statsParts.push(`<span style="color:rgba(74,222,128,0.7)" title="Already in library">${owned} owned</span>`);
|
||||||
|
if (downloaded > 0) statsParts.push(`<span style="color:rgba(96,165,250,0.7)" title="Newly downloaded">${downloaded} new</span>`);
|
||||||
|
}
|
||||||
|
if (failed > 0) statsParts.push(`<span style="color:#ef4444" title="Failed to download">${failed} failed</span>`);
|
||||||
|
const notFound = total - found - failed;
|
||||||
|
if (notFound > 0) statsParts.push(`<span title="Not found on any source">${notFound} missing</span>`);
|
||||||
|
if (statsParts.length === 0) statsParts.push(`${total} tracks`);
|
||||||
|
|
||||||
let dateText = '';
|
let dateText = '';
|
||||||
if (h.completed_at) {
|
if (h.completed_at) {
|
||||||
|
|
@ -2850,18 +2865,50 @@ function _adlRenderBatchHistory() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceLabel = h.source_page ? `<span class="adl-batch-card-source" style="font-size:0.6rem;padding:0 4px">${_adlEsc(h.source_page)}</span>` : '';
|
// Use source (spotify, mirrored, discover, etc.) for badge when available, fall back to source_page
|
||||||
|
const badgeLabel = h.source || h.source_page || '';
|
||||||
|
const sourceLabel = badgeLabel ? `<span class="adl-batch-card-source" style="font-size:0.6rem;padding:0 4px">${_adlEsc(badgeLabel)}</span>` : '';
|
||||||
|
|
||||||
// Source type color dot
|
// Source type color dot - expanded palette
|
||||||
const sourceColors = { wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129' };
|
const sourceColors = {
|
||||||
const dotColor = sourceColors[h.source_page] || '255, 255, 255';
|
wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129',
|
||||||
|
discover: '251, 191, 36', mirrored: '236, 72, 153', spotify: '30, 215, 96',
|
||||||
|
youtube: '255, 0, 0', tidal: '0, 255, 255', deezer: '162, 73, 255',
|
||||||
|
beatport: '148, 252, 19', listenbrainz: '255, 134, 0'
|
||||||
|
};
|
||||||
|
const dotColor = sourceColors[h.source] || sourceColors[h.source_page] || '255, 255, 255';
|
||||||
const histDot = `<span class="adl-batch-history-dot" style="background:rgba(${dotColor}, 0.6)"></span>`;
|
const histDot = `<span class="adl-batch-history-dot" style="background:rgba(${dotColor}, 0.6)"></span>`;
|
||||||
|
|
||||||
|
// Optional thumbnail
|
||||||
|
const thumb = h.thumb_url
|
||||||
|
? `<img src="${_adlEsc(h.thumb_url)}" class="adl-batch-history-thumb" loading="lazy">`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Server push status indicator
|
||||||
|
let pushBadge = '';
|
||||||
|
if (h.server_push_status) {
|
||||||
|
const pushIcons = {
|
||||||
|
pending: ['⏳', 'rgba(251,191,36,0.7)', 'Waiting to push to server'],
|
||||||
|
pushing: ['⬆', 'rgba(96,165,250,0.7)', 'Pushing to server...'],
|
||||||
|
success: ['✓', 'rgba(74,222,128,0.7)', 'Pushed to server'],
|
||||||
|
failed: ['✗', 'rgba(239,68,68,0.7)', 'Server push failed'],
|
||||||
|
skipped: ['—', 'rgba(255,255,255,0.2)', 'Server push skipped'],
|
||||||
|
};
|
||||||
|
const [icon, color, tip] = pushIcons[h.server_push_status] || ['?', 'rgba(255,255,255,0.3)', h.server_push_status];
|
||||||
|
pushBadge = ` · <span style="color:${color}" title="${tip}">${icon} server</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `<div class="adl-batch-history-item">
|
return `<div class="adl-batch-history-item">
|
||||||
${histDot}
|
${thumb}
|
||||||
<div class="adl-batch-history-name">${name} ${sourceLabel}</div>
|
<div class="adl-batch-history-content">
|
||||||
<div class="adl-batch-history-stats">${statsParts.join(' ')}</div>
|
<div class="adl-batch-history-row1">
|
||||||
<div class="adl-batch-history-date">${dateText}</div>
|
${histDot}
|
||||||
|
<span class="adl-batch-history-name">${name}</span>
|
||||||
|
${sourceLabel}
|
||||||
|
<span class="adl-batch-history-date">${dateText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="adl-batch-history-row2">${statsParts.join(' · ')}${pushBadge}</div>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56809,7 +56809,7 @@ body.reduce-effects *::after {
|
||||||
|
|
||||||
/* ---- Batch Context Panel ---- */
|
/* ---- Batch Context Panel ---- */
|
||||||
.adl-batch-panel {
|
.adl-batch-panel {
|
||||||
width: 340px;
|
width: 400px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
padding: 0 12px 0 24px;
|
padding: 0 12px 0 24px;
|
||||||
|
|
@ -57188,29 +57188,51 @@ body.reduce-effects *::after {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 0;
|
padding: 7px 0;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.adl-batch-history-name {
|
.adl-batch-history-thumb {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 3px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-batch-history-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-batch-history-row1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adl-batch-history-name {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.55);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.adl-batch-history-stats {
|
.adl-batch-history-row2 {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
flex-shrink: 0;
|
margin-top: 2px;
|
||||||
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.adl-batch-history-date {
|
.adl-batch-history-date {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: rgba(255, 255, 255, 0.2);
|
color: rgba(255, 255, 255, 0.2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Responsive ---- */
|
/* ---- Responsive ---- */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue