Compare commits

..

No commits in common. "e40eb213b417bb3baa5a41eb8120645a4c370924" and "055c2c25c548953bb693fe9b70d6fe007af05292" have entirely different histories.

7 changed files with 202 additions and 556 deletions

View file

@ -371,13 +371,10 @@ class SeasonalDiscoveryService:
config = SEASONAL_CONFIG[season_key]
keywords = config['keywords']
# Each source stores IDs in its own column
if source == 'itunes':
track_id_col = 'itunes_track_id'
elif source == 'deezer':
track_id_col = 'deezer_track_id'
else:
track_id_col = 'spotify_track_id'
# itunes stores IDs in itunes_track_id; all other sources
# (spotify, deezer, discogs, hydrabase, etc.) use spotify_track_id
# as the generic ID column.
track_id_col = 'itunes_track_id' if source == 'itunes' else 'spotify_track_id'
seasonal_tracks = []

View file

@ -2545,8 +2545,6 @@ def _get_file_lock(file_path):
# --- Download Missing Tracks Modal State Management ---
# Thread-safe state tracking for modal download functionality with batch management
missing_download_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="MissingTrackWorker")
# Separate executor for analysis to prevent starvation when download workers are busy
analysis_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="AnalysisWorker")
download_tasks = {} # task_id -> task state dict
download_batches = {} # batch_id -> {queue, active_count, max_concurrent}
tasks_lock = threading.Lock()
@ -3916,7 +3914,6 @@ def _shutdown_runtime_components():
(retag_executor, "retag executor"),
(sync_executor, "sync executor"),
(missing_download_executor, "missing download executor"),
(analysis_executor, "analysis executor"),
(tidal_discovery_executor, "tidal discovery executor"),
(deezer_discovery_executor, "deezer discovery executor"),
(spotify_public_discovery_executor, "spotify public discovery executor"),
@ -24870,7 +24867,7 @@ def _process_wishlist_automatically(automation_id=None):
log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success')
# Submit the wishlist processing job using existing infrastructure
_submit_or_queue_batch(batch_id, playlist_id, wishlist_tracks)
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
# Don't mark auto_processing as False here - let completion handler do it
@ -26068,7 +26065,7 @@ def start_wishlist_missing_downloads():
}
# Submit the wishlist processing job using the same processing function
_submit_or_queue_batch(batch_id, playlist_id, wishlist_tracks)
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
return jsonify({
"success": True,
@ -28875,7 +28872,7 @@ def _on_download_completed(batch_id, task_id, success=True):
'listenbrainz_', 'beatport_',
)
if playlist_id and playlist_id.startswith(_push_prefixes):
get_database().update_sync_history_push_status(batch_id, 'pending')
database.update_sync_history_push_status(batch_id, 'pending')
threading.Thread(
target=_push_playlist_to_server,
args=(batch_id, batch),
@ -28991,43 +28988,6 @@ def _on_download_completed(batch_id, task_id, success=True):
logger.info(f"[Batch Manager] Starting next batch for {batch_id}")
_start_next_batch_of_downloads(batch_id)
def _submit_or_queue_batch(batch_id, playlist_id, tracks):
"""Submit a batch for analysis, or queue it if 3 analysis slots are full."""
with tasks_lock:
active_analysis_count = sum(1 for b in download_batches.values()
if b.get('phase') == 'analysis')
if active_analysis_count >= 3:
download_batches[batch_id]['phase'] = 'queued'
download_batches[batch_id]['_queued_tracks'] = tracks
download_batches[batch_id]['_queued_playlist_id'] = playlist_id
name = download_batches[batch_id].get('playlist_name', batch_id)
logger.info(f"[Queue] Batch {batch_id} ('{name}') queued — {active_analysis_count} analysis already running")
return
analysis_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, tracks)
def _promote_queued_batches():
"""Check for queued batches and promote them to analysis if capacity allows."""
with tasks_lock:
active_analysis_count = sum(1 for b in download_batches.values()
if b.get('phase') == 'analysis')
if active_analysis_count >= 3:
return
# Find batches waiting in queue, ordered by creation (dict insertion order)
for bid, batch in list(download_batches.items()):
if batch.get('phase') != 'queued':
continue
queued_tracks = batch.pop('_queued_tracks', None)
queued_pid = batch.pop('_queued_playlist_id', None)
if queued_tracks and queued_pid:
logger.info(f"[Queue] Promoting batch {bid} ('{batch.get('playlist_name')}') from queued -> analysis")
analysis_executor.submit(_run_full_missing_tracks_process, bid, queued_pid, queued_tracks)
active_analysis_count += 1
if active_analysis_count >= 3:
break
def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
"""
A master worker that handles the entire missing tracks process:
@ -29417,12 +29377,6 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
download_batches[batch_id]['phase'] = 'downloading'
# Analysis slot freed — promote any queued batches
_promote_queued_batches()
with tasks_lock:
if batch_id not in download_batches: return
# Store album pre-flight results on batch for source reuse
if preflight_source and preflight_tracks:
download_batches[batch_id]['last_good_source'] = preflight_source
@ -31148,8 +31102,6 @@ def start_playlist_missing_downloads(playlist_id):
'active_count': 0,
'max_concurrent': _get_max_concurrent(),
'queue_index': 0,
'playlist_id': playlist_id,
'playlist_name': playlist_name,
# Track state management (replicating sync.py)
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
@ -31572,26 +31524,13 @@ 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 (both image_url and images[] formats)
# Try album images
if not artwork:
raw_alb = track_info.get('album')
if isinstance(raw_alb, dict):
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', '')
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')
# Determine download progress percentage
@ -31635,86 +31574,6 @@ 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:
@ -31729,7 +31588,6 @@ 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')),
@ -31776,16 +31634,9 @@ def clear_completed_downloads():
for tid in task_ids_to_remove:
del download_tasks[tid]
cleared += 1
# Also clean up empty batches (but not those still actively processing)
active_phases = {'analysis', 'downloading', 'queued'}
# Also clean up empty batches
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)
@ -32287,7 +32138,7 @@ def _check_batch_completion_v2(batch_id):
'listenbrainz_', 'beatport_',
)
if playlist_id and playlist_id.startswith(_push_prefixes):
get_database().update_sync_history_push_status(batch_id, 'pending')
database.update_sync_history_push_status(batch_id, 'pending')
threading.Thread(
target=_push_playlist_to_server,
args=(batch_id, batch),
@ -33574,6 +33425,16 @@ def start_missing_tracks_process(playlist_id):
if playlist_folder_mode:
logger.info(f"[Playlist Folder] Enabled for playlist: '{playlist_name}'")
# Limit concurrent analysis processes to prevent resource exhaustion
with tasks_lock:
active_analysis_count = sum(1 for batch in download_batches.values()
if batch.get('phase') == 'analysis')
if active_analysis_count >= 3: # Allow max 3 concurrent analysis processes
return jsonify({
"success": False,
"error": "Too many analysis processes running. Please wait for one to complete."
}), 429
batch_id = str(uuid.uuid4())
with tasks_lock:
@ -33658,7 +33519,7 @@ def start_missing_tracks_process(playlist_id):
if '_original_index' not in track:
track['_original_index'] = i
_submit_or_queue_batch(batch_id, playlist_id, tracks)
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, tracks)
return jsonify({
"success": True,
@ -33693,8 +33554,6 @@ def start_missing_downloads():
'active_count': 0,
'max_concurrent': _get_max_concurrent(),
'queue_index': 0,
'playlist_id': playlist_id,
'playlist_name': 'Legacy Modal',
# Track state management (replicating sync.py)
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
@ -44263,7 +44122,7 @@ def get_discover_synced_playlists():
try:
with database._get_connection() as conn:
pool_count = conn.execute(
"SELECT COUNT(*) FROM discovery_pool WHERE source = ? AND profile_id = ?", (active_source, pid)
"SELECT COUNT(*) FROM discovery_pool WHERE source = ?", (active_source,)
).fetchone()[0]
except Exception:
pool_count = 0

View file

@ -9077,104 +9077,78 @@ async function loadDiscoverSyncPlaylists() {
}
}
// Grid container lazy-init — we wrap cards in a .discover-sync-grid div so
// the vertical scroll container holds a proper grid. Only one grid per
// container; subsequent calls append to the existing grid.
function _getOrCreateDiscoverSyncGrid(container) {
let grid = container.querySelector(':scope > .discover-sync-grid');
if (!grid) {
grid = document.createElement('div');
grid.className = 'discover-sync-grid';
container.appendChild(grid);
}
return grid;
}
// Deterministic hue per playlist type — same playlist always gets the same
// gradient-glow color across reloads. Mirrors the i*37+200 formula used by
// server-pl-card for color-wheel spread.
function _discoverSyncHue(playlistType) {
let hash = 0;
const s = String(playlistType || '');
for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0;
return ((hash % 360) + 360) % 360;
}
function renderDiscoverSyncCard(playlist, container, sourceLabel) {
const grid = _getOrCreateDiscoverSyncGrid(container);
const card = document.createElement('div');
const isEmpty = playlist.track_count === 0;
card.className = `discover-sync-card${isEmpty ? ' discover-sync-card-empty' : ''}`;
card.id = `discover-sync-card-${playlist.type}`;
card.style.setProperty('--card-hue', _discoverSyncHue(playlist.type));
const lastSyncedText = playlist.last_synced
? `Synced ${timeAgo(playlist.last_synced)}`
? `Last synced ${timeAgo(playlist.last_synced)}`
: 'Never synced';
const statusClass = playlist.sync_status === 'syncing' ? 'syncing' :
playlist.sync_status === 'synced' ? 'synced' : 'not-synced';
// Text format mirrors the strings the sync-poll code writes back into
// this element on transitions (see pollDiscoverBatchFromTab) — keeping
// them aligned means the user sees consistent text across the
// render → syncing → synced lifecycle.
let statusText = playlist.sync_status === 'syncing' ? 'Syncing...' :
playlist.sync_status === 'synced' ? 'Synced' : 'Not synced';
// Show matched/total counts if available (only when matched > 0, meaning completion was recorded)
if (playlist.sync_status === 'synced' && playlist.matched_tracks > 0 && playlist.total_sync_tracks > 0) {
statusText = `Synced ${playlist.matched_tracks}/${playlist.total_sync_tracks}`;
}
const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`;
const safePlaylistName = (playlist.name || '').replace(/'/g, "\\'");
card.innerHTML = `
<div class="discover-sync-card-glow"></div>
<div class="discover-sync-card-top">
<div class="discover-sync-card-icon">${playlist.icon || '🎵'}</div>
<div class="discover-sync-source-badge">${sourceLabel || 'unknown'}</div>
</div>
<div class="discover-sync-card-body">
<div class="discover-sync-card-name">${playlist.name}</div>
<div class="discover-sync-card-meta">
<span class="discover-sync-track-count">${trackLabel}</span>
<span class="discover-sync-status ${statusClass}">${statusText}</span>
<div class="discover-sync-card-icon">${playlist.icon}</div>
<div class="discover-sync-card-info">
<div class="discover-sync-card-name">${playlist.name}
<span class="discover-sync-card-meta-inline">
<span class="discover-sync-source-badge">${sourceLabel || 'unknown'}</span>
<span class="discover-sync-separator">\u00b7</span>
<span class="discover-sync-track-count">${trackLabel}</span>
<span class="discover-sync-separator">\u00b7</span>
<span class="discover-sync-status ${statusClass}">${statusText}</span>
<span class="discover-sync-separator">\u00b7</span>
<span class="discover-sync-last-synced">${lastSyncedText}</span>
</span>
</div>
<div class="discover-sync-last-synced">${lastSyncedText}</div>
</div>
<div class="discover-sync-card-toggles">
<label class="discover-sync-toggle-row" title="${isEmpty ? 'No tracks available — visit Discover first' : 'Keep this playlist updated automatically'}">
<span class="discover-sync-toggle-label">Keep updated</span>
<span class="discover-sync-toggle">
<div class="discover-sync-card-actions">
<div class="discover-sync-toggle-wrapper" title="${isEmpty ? 'No tracks available — visit Discover first' : 'Keep this playlist updated automatically'}">
<label class="discover-sync-toggle-label">Keep updated</label>
<label class="discover-sync-toggle">
<input type="checkbox" ${playlist.auto_update ? 'checked' : ''} ${isEmpty ? 'disabled' : ''}
onchange="toggleDiscoverAutoUpdate('${playlist.type}', this.checked)">
<span class="discover-sync-toggle-slider"></span>
</span>
</label>
<label class="discover-sync-toggle-row disabled" title="Coming soon: download any available quality for this batch, even if it's below your global quality profile. Useful for rotating discover playlists where quantity matters more than quality.">
<span class="discover-sync-toggle-label">Any Quality</span>
<span class="discover-sync-toggle">
<input type="checkbox" id="discover-any-quality-${playlist.type}" disabled>
</label>
</div>
<div class="discover-sync-toggle-wrapper" title="${isEmpty ? '' : 'Skip quality filters and download any available source'}">
<label class="discover-sync-toggle-label">Force DL</label>
<label class="discover-sync-toggle">
<input type="checkbox" id="discover-force-dl-${playlist.type}" ${isEmpty ? 'disabled' : ''}>
<span class="discover-sync-toggle-slider"></span>
</span>
</label>
</label>
</div>
<button class="discover-sync-btn" id="discover-sync-btn-${playlist.type}"
onclick="syncDiscoverPlaylistFromTab('${playlist.type}', '${playlist.name}')"
${playlist.sync_status === 'syncing' || isEmpty ? 'disabled' : ''}>
\u27f3 Sync Now
</button>
</div>
<button class="discover-sync-card-action" id="discover-sync-btn-${playlist.type}"
onclick="event.stopPropagation(); syncDiscoverPlaylistFromTab('${playlist.type}', '${safePlaylistName}')"
${playlist.sync_status === 'syncing' || isEmpty ? 'disabled' : ''}> Sync Now</button>
`;
// Clicking the top (icon + badge) or body (name + meta) opens the track list.
// The Sync Now button stops propagation so it doesn't double-trigger.
// Make the icon + info area clickable to view tracks
if (!isEmpty) {
const openTracks = () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon);
card.querySelectorAll('.discover-sync-card-top, .discover-sync-card-body').forEach(el => {
const clickArea = card.querySelector('.discover-sync-card-info');
const iconArea = card.querySelector('.discover-sync-card-icon');
[clickArea, iconArea].forEach(el => {
el.style.cursor = 'pointer';
el.addEventListener('click', openTracks);
el.addEventListener('click', () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon));
});
}
grid.appendChild(card);
container.appendChild(card);
}
async function toggleDiscoverAutoUpdate(playlistType, enabled) {
@ -9199,10 +9173,15 @@ async function toggleDiscoverAutoUpdate(playlistType, enabled) {
const _discoverSyncQueue = [];
let _discoverSyncRunning = false;
async function syncDiscoverPlaylistFromTab(playlistType, playlistName) {
async function syncDiscoverPlaylistFromTab(playlistType, playlistName, forceDownload) {
// Read force-download toggle if not explicitly passed
if (forceDownload === undefined) {
const fdToggle = document.getElementById(`discover-force-dl-${playlistType}`);
forceDownload = fdToggle ? fdToggle.checked : false;
}
// Serialize sync operations to avoid concurrent backend contention
return new Promise((resolve) => {
_discoverSyncQueue.push({ playlistType, playlistName, resolve });
_discoverSyncQueue.push({ playlistType, playlistName, forceDownload, resolve });
_processDiscoverSyncQueue();
});
}
@ -9210,9 +9189,9 @@ async function syncDiscoverPlaylistFromTab(playlistType, playlistName) {
async function _processDiscoverSyncQueue() {
if (_discoverSyncRunning || _discoverSyncQueue.length === 0) return;
_discoverSyncRunning = true;
const { playlistType, playlistName, resolve } = _discoverSyncQueue.shift();
const { playlistType, playlistName, forceDownload, resolve } = _discoverSyncQueue.shift();
try {
await _doSyncDiscoverPlaylist(playlistType, playlistName);
await _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload);
} finally {
_discoverSyncRunning = false;
resolve();
@ -9220,7 +9199,7 @@ async function _processDiscoverSyncQueue() {
}
}
async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload) {
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
if (btn) {
btn.disabled = true;
@ -9274,6 +9253,7 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
tracks: syncTracks,
playlist_name: playlistName
};
if (forceDownload) bodyPayload.force_download_all = true;
const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, {
method: 'POST',
@ -9283,7 +9263,8 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
const result = await batchResponse.json();
if (result.success) {
showToast(`Downloading ${playlistName} (${syncTracks.length} tracks)...`, 'info');
const forceLabel = forceDownload ? ' (force download)' : '';
showToast(`Downloading ${playlistName}${forceLabel} (${syncTracks.length} tracks)...`, 'info');
const card = document.getElementById(`discover-sync-card-${playlistType}`);
if (card) {
const statusEl = card.querySelector('.discover-sync-status');

View file

@ -2250,8 +2250,6 @@ async function loadPageData(pageId) {
delete discoverSyncPollers[key];
}
}
// Reset so discover tab refetches on next visit
discoverSyncPlaylistsLoaded = false;
}
switch (pageId) {
case 'dashboard':

View file

@ -2163,14 +2163,14 @@ 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;
const _BATCH_COLOR_COUNT = 16;
function _getBatchColor(batchId) {
if (!batchId) return -1;
if (_batchColorMap[batchId] === undefined) {
// Assign colors sequentially so no duplicates until all 16 are used
_batchColorMap[batchId] = _batchColorNext % _BATCH_COLOR_COUNT;
_batchColorNext++;
// 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];
}
@ -2488,10 +2488,6 @@ 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,11 +2507,8 @@ function _adlRenderBatchPanel() {
// Phase label with icon
let phaseText = '';
let phaseIcon = '';
if (batch.phase === 'queued') {
phaseText = 'Queued';
phaseIcon = '<span style="color:#eab308;margin-right:4px">⏳</span>';
} else if (batch.phase === 'analysis') {
phaseText = analysisTotal > 0 ? `Analyzing ${analysisProcessed}/${analysisTotal}...` : 'Analyzing...';
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`;
@ -2614,7 +2607,7 @@ function _adlRenderBatchPanel() {
</div>
</div>
<div class="adl-batch-progress">
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${isAnalyzing ? analysisPct : pct}%"></div>
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
</div>
<div class="adl-batch-tracks">${tracksHtml}</div>
</div>`;
@ -2834,21 +2827,10 @@ 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;
// 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`);
const statsParts = [`${downloaded}/${total}`];
if (failed > 0) statsParts.push(`<span style="color:#ef4444">${failed} failed</span>`);
let dateText = '';
if (h.completed_at) {
@ -2865,66 +2847,18 @@ function _adlRenderBatchHistory() {
}
}
// 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>` : '';
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 - 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 dotSource = h.source || h.source_page || '';
const dotColor = sourceColors[dotSource] || '255, 255, 255';
const dotTip = dotSource ? `Source: ${dotSource}` : 'Unknown source';
const histDot = `<span class="adl-batch-history-dot" style="background:rgba(${dotColor}, 0.6)" title="${dotTip}"></span>`;
// Thumbnail with playlist-type icon fallback
let placeholderIcon = '♫';
const pName = (h.playlist_name || '').toLowerCase();
if (pName.includes('fresh tape') || pName.includes('release radar')) placeholderIcon = '🎵';
else if (pName.includes('archives') || pName.includes('discovery weekly')) placeholderIcon = '📚';
else if (pName.includes('seasonal')) placeholderIcon = '🌿';
else if (pName.includes('popular picks')) placeholderIcon = '🔥';
else if (pName.includes('hidden gems')) placeholderIcon = '💎';
else if (pName.includes('discovery shuffle')) placeholderIcon = '🔀';
else if (pName.includes('familiar fav')) placeholderIcon = '❤️';
else if (pName.includes('jam')) placeholderIcon = '🎸';
else if (pName.includes('explor')) placeholderIcon = '🔭';
else if (dotSource === 'mirrored' || dotSource === 'youtube') placeholderIcon = '🔗';
else if (dotSource === 'wishlist') placeholderIcon = '⭐';
else if (dotSource === 'album') placeholderIcon = '💿';
const thumb = h.thumb_url
? `<img src="${_adlEsc(h.thumb_url)}" class="adl-batch-history-thumb" loading="lazy" data-icon="${placeholderIcon}" onerror="var s=document.createElement('span');s.className='adl-batch-history-thumb adl-batch-history-thumb-placeholder';s.textContent=this.dataset.icon;this.parentNode.replaceChild(s,this)">`
: `<span class="adl-batch-history-thumb adl-batch-history-thumb-placeholder">${placeholderIcon}</span>`;
// 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>`;
}
// 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">
${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>
${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('');
}

View file

@ -56371,14 +56371,6 @@ body.reduce-effects *::after {
--batch-color-5: 239, 68, 68; /* red */
--batch-color-6: 6, 182, 212; /* cyan */
--batch-color-7: 168, 85, 247; /* purple */
--batch-color-8: 234, 179, 8; /* yellow */
--batch-color-9: 249, 115, 22; /* orange */
--batch-color-10: 34, 197, 94; /* green */
--batch-color-11: 99, 102, 241; /* indigo */
--batch-color-12: 244, 63, 94; /* rose */
--batch-color-13: 20, 184, 166; /* teal */
--batch-color-14: 217, 70, 239; /* fuchsia */
--batch-color-15: 56, 189, 248; /* sky */
}
.adl-layout {
@ -56809,7 +56801,7 @@ body.reduce-effects *::after {
/* ---- Batch Context Panel ---- */
.adl-batch-panel {
width: 400px;
width: 340px;
flex-shrink: 0;
border-left: 1px solid rgba(255, 255, 255, 0.06);
padding: 0 12px 0 24px;
@ -57188,66 +57180,29 @@ body.reduce-effects *::after {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 0;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
}
.adl-batch-history-thumb {
width: 28px;
height: 28px;
border-radius: 3px;
object-fit: cover;
flex-shrink: 0;
}
.adl-batch-history-thumb-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.06);
font-size: 14px;
}
.adl-batch-history-thumb-placeholder:empty::after {
content: '♫';
font-size: 12px;
color: rgba(255, 255, 255, 0.2);
}
.adl-batch-history-content {
flex: 1;
min-width: 0;
}
.adl-batch-history-row1 {
display: flex;
align-items: center;
gap: 6px;
}
.adl-batch-history-name {
flex: 1;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.55);
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.adl-batch-history-row2 {
.adl-batch-history-stats {
font-size: 0.68rem;
color: rgba(255, 255, 255, 0.3);
margin-top: 2px;
padding-left: 12px;
flex-shrink: 0;
}
.adl-batch-history-date {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
margin-left: auto;
}
/* ---- Responsive ---- */
@ -59567,7 +59522,6 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
#artist-detail-page .release-card.album-card .mb-card-icon:hover {
opacity: 1;
}
/* ── SoulSync Discover Sync Tab ───────────────────────────────────────── */
@ -59593,197 +59547,135 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
line-height: 1.4;
}
/* ─────────────────────────────────────────────────────────────────────── */
/* SoulSync Discover cards — grid layout mirroring server-pl-card pattern */
/* ─────────────────────────────────────────────────────────────────────── */
.discover-sync-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
padding: 4px;
}
.discover-sync-card {
position: relative;
background: rgba(255, 255, 255, 0.03);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px 14px 12px;
border-radius: 10px;
margin: 3px 6px;
padding: 8px 14px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 260px;
overflow: hidden;
transition: transform 0.25s ease, border-color 0.25s ease, background 0.25s ease;
align-items: center;
gap: 10px;
transition: all 0.25s ease;
}
.discover-sync-card:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.05);
border-color: hsla(var(--card-hue, 260), 80%, 70%, 0.35);
}
.discover-sync-card-glow {
position: absolute;
top: -40%;
right: -20%;
width: 160px;
height: 160px;
background: radial-gradient(circle,
hsla(var(--card-hue, 260), 80%, 60%, 0.22) 0%,
hsla(var(--card-hue, 260), 80%, 60%, 0) 70%);
pointer-events: none;
z-index: 0;
transition: opacity 0.25s ease;
}
.discover-sync-card:hover .discover-sync-card-glow {
opacity: 1.4;
}
.discover-sync-card > *:not(.discover-sync-card-glow) {
position: relative;
z-index: 1;
}
/* ── Top row: icon + source badge ── */
.discover-sync-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255, 255, 255, 0.06);
border-color: rgba(167, 139, 250, 0.2);
}
.discover-sync-card-icon {
font-size: 22px;
width: 40px;
height: 40px;
font-size: 20px;
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg,
hsla(var(--card-hue, 260), 70%, 55%, 0.2),
hsla(var(--card-hue, 260), 70%, 40%, 0.1));
border: 1px solid hsla(var(--card-hue, 260), 70%, 60%, 0.2);
border-radius: 10px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.discover-sync-source-badge {
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 3px 8px;
border-radius: 5px;
color: hsla(var(--card-hue, 260), 80%, 80%, 1);
background: hsla(var(--card-hue, 260), 70%, 55%, 0.12);
border: 1px solid hsla(var(--card-hue, 260), 70%, 60%, 0.25);
white-space: nowrap;
}
/* ── Body: name + meta line + last-synced ── */
.discover-sync-card-body {
display: flex;
flex-direction: column;
gap: 4px;
.discover-sync-card-info {
flex: 1;
min-width: 0;
}
.discover-sync-card-name {
font-size: 15px;
font-weight: 700;
font-size: 13px;
font-weight: 600;
color: #fff;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.discover-sync-card-meta-inline {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 400;
color: rgba(255, 255, 255, 0.4);
}
.discover-sync-source-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 7px;
border-radius: 4px;
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb), 0.1);
border: 1px solid rgba(var(--accent-rgb), 0.2);
}
.discover-sync-card-desc {
display: none;
}
.discover-sync-card-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
font-weight: 500;
margin-top: 2px;
display: none;
}
color: rgba(255, 255, 255, 0.4);
}
.discover-sync-separator {
opacity: 0.4;
}
.discover-sync-track-count {
color: rgba(255, 255, 255, 0.6);
color: rgba(255, 255, 255, 0.55);
}
.discover-sync-status {
font-weight: 600;
padding: 2px 7px;
border-radius: 4px;
font-size: 10.5px;
letter-spacing: 0.2px;
font-weight: 500;
}
.discover-sync-status.synced {
color: #4ade80;
background: rgba(34, 197, 94, 0.12);
color: #22c55e;
}
.discover-sync-status.syncing {
color: #fde047;
background: rgba(250, 204, 21, 0.14);
color: #facc15;
}
.discover-sync-status.not-synced {
color: rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.35);
}
.discover-sync-last-synced {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
margin-top: 2px;
color: rgba(255, 255, 255, 0.35);
}
/* ── Toggles (stacked rows of label + switch) ── */
.discover-sync-card-toggles {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.discover-sync-toggle-row {
.discover-sync-card-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
cursor: pointer;
min-height: 22px;
gap: 16px;
flex-shrink: 0;
}
.discover-sync-toggle-row.disabled {
opacity: 0.45;
cursor: not-allowed;
.discover-sync-toggle-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.discover-sync-toggle-label {
font-size: 11.5px;
font-weight: 500;
color: rgba(255, 255, 255, 0.65);
font-size: 10.5px;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
}
.discover-sync-toggle {
position: relative;
display: inline-block;
width: 34px;
height: 18px;
flex-shrink: 0;
width: 40px;
height: 22px;
cursor: pointer;
}
.discover-sync-toggle input {
@ -59799,65 +59691,56 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.12);
border-radius: 18px;
border-radius: 22px;
transition: all 0.25s ease;
}
.discover-sync-toggle-slider::before {
content: '';
position: absolute;
width: 14px;
height: 14px;
left: 2px;
bottom: 2px;
width: 16px;
height: 16px;
left: 3px;
bottom: 3px;
background: #fff;
border-radius: 50%;
transition: all 0.25s ease;
}
.discover-sync-toggle input:checked + .discover-sync-toggle-slider {
background: hsla(var(--card-hue, 260), 75%, 60%, 1);
background: #a78bfa;
}
.discover-sync-toggle input:checked + .discover-sync-toggle-slider::before {
transform: translateX(16px);
transform: translateX(18px);
}
/* ── Primary action button ── */
/* Kept as a flat-text button (no nested children) because the sync-poll
code writes `btn.textContent = 'Syncing…'` / `'⟳ Sync Now'` on state
transitions any child elements would be wiped on every toggle. */
.discover-sync-card-action {
display: block;
width: 100%;
padding: 10px 14px;
background: linear-gradient(135deg,
hsla(var(--card-hue, 260), 70%, 55%, 0.85),
hsla(var(--card-hue, 260), 70%, 42%, 0.85));
.discover-sync-btn {
padding: 8px 16px;
background: linear-gradient(135deg, #a78bfa, #7c3aed);
color: #fff;
border: 1px solid hsla(var(--card-hue, 260), 70%, 65%, 0.35);
border-radius: 10px;
border: none;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
margin-top: auto;
white-space: nowrap;
transition: all 0.25s ease;
}
.discover-sync-card-action:hover:not(:disabled) {
.discover-sync-btn:hover:not(:disabled) {
transform: translateY(-1px);
filter: brightness(1.08);
box-shadow: 0 6px 18px hsla(var(--card-hue, 260), 70%, 50%, 0.35);
box-shadow: 0 4px 12px rgba(167, 139, 250, 0.35);
}
.discover-sync-card-action:disabled {
opacity: 0.45;
.discover-sync-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
filter: grayscale(30%);
transform: none;
box-shadow: none;
}
/* ── Empty / highlight states ── */
/* Empty-state styling */
.discover-sync-card-empty {
opacity: 0.55;
}
@ -59866,22 +59749,16 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
opacity: 0.4;
}
/* Deep-link highlight animation */
.discover-sync-card-highlight {
animation: discover-card-glow 2.5s ease-out;
}
@keyframes discover-card-glow {
0% {
border-color: hsla(var(--card-hue, 260), 80%, 70%, 0.9);
box-shadow: 0 0 24px hsla(var(--card-hue, 260), 80%, 60%, 0.4);
}
100% {
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
0% { border-color: rgba(167, 139, 250, 0.7); box-shadow: 0 0 20px rgba(167, 139, 250, 0.3); }
100% { border-color: rgba(255, 255, 255, 0.08); box-shadow: none; }
}
/* ── Existing empty-state hint + source info (unchanged) ── */
.discover-sync-empty-hint {
background: rgba(255, 193, 7, 0.08);
border: 1px solid rgba(255, 193, 7, 0.25);
@ -59917,14 +59794,14 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
}
@media (max-width: 768px) {
.discover-sync-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
.discover-sync-card {
flex-wrap: wrap;
gap: 12px;
}
.discover-sync-card {
min-height: 240px;
padding: 12px;
.discover-sync-card-actions {
width: 100%;
justify-content: flex-end;
}
}

View file

@ -2777,7 +2777,7 @@ function _applySyncTabAction() {
}
}
if (action.autoSync) {
syncDiscoverPlaylistFromTab(action.autoSync, action.autoSyncName || action.autoSync);
syncDiscoverPlaylistFromTab(action.autoSync, action.autoSyncName || action.autoSync, action.forceDownload);
}
};
// Small delay to let lazy tab content render