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 ''
|
||||
|
||||
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:
|
||||
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])
|
||||
artwork = raw_alb.get('image_url') or ''
|
||||
if not artwork:
|
||||
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')
|
||||
# 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
|
||||
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
|
||||
batch_summaries = []
|
||||
with tasks_lock:
|
||||
|
|
@ -31634,6 +31727,7 @@ def get_all_downloads_unified():
|
|||
'phase': batch.get('phase', 'unknown'),
|
||||
'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')),
|
||||
'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')),
|
||||
|
|
@ -31680,9 +31774,16 @@ def clear_completed_downloads():
|
|||
for tid in task_ids_to_remove:
|
||||
del download_tasks[tid]
|
||||
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 = []
|
||||
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]
|
||||
if not remaining:
|
||||
empty_batches.append(bid)
|
||||
|
|
|
|||
|
|
@ -2488,6 +2488,10 @@ function _adlRenderBatchPanel() {
|
|||
const hasFailed = batch.failed > 0;
|
||||
const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error';
|
||||
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
|
||||
let fadeStyle = '';
|
||||
|
|
@ -2511,7 +2515,7 @@ function _adlRenderBatchPanel() {
|
|||
phaseText = 'Queued';
|
||||
phaseIcon = '<span style="color:#eab308;margin-right:4px">⏳</span>';
|
||||
} else if (batch.phase === 'analysis') {
|
||||
phaseText = 'Analyzing...';
|
||||
phaseText = analysisTotal > 0 ? `Analyzing ${analysisProcessed}/${analysisTotal}...` : 'Analyzing...';
|
||||
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
|
||||
} else if (batch.phase === 'downloading') {
|
||||
phaseText = `${batch.completed}/${total} tracks`;
|
||||
|
|
@ -2610,7 +2614,7 @@ function _adlRenderBatchPanel() {
|
|||
</div>
|
||||
</div>
|
||||
<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 class="adl-batch-tracks">${tracksHtml}</div>
|
||||
</div>`;
|
||||
|
|
@ -2830,10 +2834,21 @@ function _adlRenderBatchHistory() {
|
|||
list.innerHTML = _adlBatchHistory.map(h => {
|
||||
const name = _adlEsc(h.playlist_name || 'Unknown');
|
||||
const downloaded = h.tracks_downloaded || 0;
|
||||
const found = h.tracks_found || 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>`);
|
||||
|
||||
// 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 = '';
|
||||
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
|
||||
const sourceColors = { wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129' };
|
||||
const dotColor = sourceColors[h.source_page] || '255, 255, 255';
|
||||
// Source type color dot - expanded palette
|
||||
const sourceColors = {
|
||||
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>`;
|
||||
|
||||
// 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">
|
||||
${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>
|
||||
${thumb}
|
||||
<div class="adl-batch-history-content">
|
||||
<div class="adl-batch-history-row1">
|
||||
${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>`;
|
||||
}).join('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56809,7 +56809,7 @@ body.reduce-effects *::after {
|
|||
|
||||
/* ---- Batch Context Panel ---- */
|
||||
.adl-batch-panel {
|
||||
width: 340px;
|
||||
width: 400px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 0 12px 0 24px;
|
||||
|
|
@ -57188,29 +57188,51 @@ body.reduce-effects *::after {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
padding: 7px 0;
|
||||
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;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.adl-batch-history-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.adl-batch-history-name {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.adl-batch-history-stats {
|
||||
.adl-batch-history-row2 {
|
||||
font-size: 0.68rem;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.adl-batch-history-date {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
|
|
|
|||
Loading…
Reference in a new issue