Compare commits

...

20 commits

Author SHA1 Message Date
Broque Thomas
e40eb213b4 Redesign SoulSync Discover cards to grid layout matching Server Playlists
Some checks failed
Compile the app and run tests / sanity-check (push) Has been cancelled
The current cards are full-width horizontal rows, which makes the tab feel
dense once a few playlists accumulate. Rework to a grid of compact cards
that match the Server Playlists visual language already used on the same
Sync page, so the two surfaces feel like the same app.

Changes (all visual — no impact on JohnBaumb's sync/poll logic):

- `.discover-sync-grid` wrapper renders cards as `repeat(auto-fill,
  minmax(240px, 1fr))`. 4-6 cards per row at desktop width.
- Per-card hue derived deterministically from playlist type so each card
  has a consistent color identity across reloads. Drives the icon tint,
  source badge color, toggle color, action-button gradient, and a soft
  radial glow in the top-right corner.
- Top row: 40x40 icon chip + source badge ("DEEZER" / "SPOTIFY") pushed
  to the top-right (mirrors the chevron placement on Server Playlists).
- Body: playlist name (15px bold, 2-line clamp), track count + status
  pill on one line, last-synced subtle line below.
- Compact toggles in a small bordered panel, stacked rows of
  label+switch. Smaller 34x18 switch matches the denser card size.
- Full-width gradient action button ("Sync Now") at the bottom.
- Hover: card lifts 2px, glow + border intensify.

Deliberately kept purely visual to avoid touching JohnBaumb's logic:
- Button content stays flat text ("Sync Now" / "Syncing...") so his
  `btn.textContent = ...` poll updates still work unchanged.
- Status text format matches the strings his sync-poll code writes
  ("Synced 10/15" / "Synced" / "Not synced" / "Syncing..." / "Failed"),
  so initial render and post-sync updates stay consistent.
- All ids and state-managed class hooks preserved: `discover-sync-card-${type}`,
  `discover-sync-btn-${type}`, `.discover-sync-status`, the
  syncing/synced/not-synced modifier classes, and the
  `discover-sync-card-highlight` deep-link animation class.

Staging branch for review — not targeting dev directly. JohnBaumb can
cherry-pick onto his PR branch if he wants the redesign, or skip it.
2026-04-24 11:21:49 -07:00
JohnBaumb
97b0ee960a fix: malformed CSS, seasonal source column, profile_id filter, discover tab stale flag
- style.css: move orphan color declaration inside .discover-sync-card-meta so downstream rules parse correctly
- seasonal_discovery.py: branch on deezer_track_id for deezer source instead of falling through to spotify_track_id
- web_server.py: add AND profile_id = ? to discovery pool count so other profiles don't inflate the result
- init.js: reset discoverSyncPlaylistsLoaded on page leave so the discover tab refetches on revisit
2026-04-24 01:04:50 -07:00
JohnBaumb
150dd046df remove plan file from branch 2026-04-23 13:43:05 -07:00
JohnBaumb
62797c68af discover: replace Force DL toggle with Any Quality placeholder (Phase 0)
Replace the misleading Force DL toggle on the Sync page Discover tab with
a disabled Any Quality toggle. Stop sending force_download_all from
discover sync so discover playlists run ownership analysis instead of
blindly re-downloading owned tracks.

Adds PLAN-download-quality-flags.md describing the full refactor plan.
2026-04-23 13:37:59 -07:00
JohnBaumb
016e8a4c16 fix: server push status NameError + discover icons as thumbnails
- Fix NameError in both completion handlers: 'database' was not defined
  at module scope, replaced with get_database() call
- Add playlist_id and playlist_name to download_missing batch dicts
  so push prefix check works for those code paths
- Replace generic music note placeholder with playlist-type emojis
  in batch history (Fresh Tape=note, Archives=books, etc.)
- Add source-based fallback icons for mirrored/wishlist/album entries
2026-04-23 12:05:37 -07:00
JohnBaumb
038a0189a4 fix: add placeholder thumbnail for history items without images 2026-04-23 11:39:26 -07:00
JohnBaumb
92077c9117 fix: add tooltip to source dot in batch history 2026-04-23 11:35:45 -07:00
JohnBaumb
1f944841a3 fix: add missing closing brace in style.css that broke discover sync CSS 2026-04-23 11:24:20 -07:00
JohnBaumb
a0eb289d63 Merge origin/fix/downloads-page into dev 2026-04-23 11:13:45 -07:00
JohnBaumb
ddae75f0f9 Merge origin/fix/batch-queue-system into dev 2026-04-23 11:13:34 -07:00
JohnBaumb
5706f32ba2 Merge upstream/dev into fix/downloads-page 2026-04-23 09:41:54 -07:00
JohnBaumb
f79cb182d4 Merge upstream/dev into fix/batch-queue-system 2026-04-23 09:33:51 -07:00
JohnBaumb
735a0ebe58 Fix missing album art on downloads page with multi-layer fallback 2026-04-23 01:48:40 -07:00
JohnBaumb
d2bd66c0ce Show server push status in batch history 2026-04-23 00:55:46 -07:00
JohnBaumb
11b74d7c14 Widen batch panel to 400px, shorten stat labels 2026-04-23 00:46:54 -07:00
JohnBaumb
9b3e65c3d8 Two-line batch history layout with descriptive stats 2026-04-23 00:44:59 -07:00
JohnBaumb
332392f338 Improve batch history: thumbnails, source badges, detailed stats 2026-04-23 00:40:33 -07:00
JohnBaumb
c3b9f97afc Add analysis progress to downloads page batch cards 2026-04-22 15:21:11 -07:00
JohnBaumb
3c06e66f03 fix: downloads page album art, total count, and clear-completed safety
- Fix missing album art on downloads page across all sources
- Fix downloads page showing wrong total count
- Enrich missing download artwork from metadata cache
- Prevent Clear Completed from removing active download batches
- Keep download limit at 300 per performance design
2026-04-22 11:59:12 -07:00
JohnBaumb
99a95dcc91 fix: replace 429 rejection with analysis queue and expand batch colors
- Queue batches for analysis instead of rejecting with 429
- Route all batch submissions through analysis queue
- Separate analysis executor to prevent thread starvation
- Expand batch color palette from 8 to 16 sequential colors
- Add queued phase display in batch panel
2026-04-22 11:58:01 -07:00
7 changed files with 558 additions and 204 deletions

View file

@ -371,10 +371,13 @@ class SeasonalDiscoveryService:
config = SEASONAL_CONFIG[season_key]
keywords = config['keywords']
# 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'
# 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'
seasonal_tracks = []

View file

@ -2545,6 +2545,8 @@ 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()
@ -3914,6 +3916,7 @@ 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"),
@ -24867,7 +24870,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
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
_submit_or_queue_batch(batch_id, playlist_id, wishlist_tracks)
# Don't mark auto_processing as False here - let completion handler do it
@ -26065,7 +26068,7 @@ def start_wishlist_missing_downloads():
}
# Submit the wishlist processing job using the same processing function
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
_submit_or_queue_batch(batch_id, playlist_id, wishlist_tracks)
return jsonify({
"success": True,
@ -28872,7 +28875,7 @@ def _on_download_completed(batch_id, task_id, success=True):
'listenbrainz_', 'beatport_',
)
if playlist_id and playlist_id.startswith(_push_prefixes):
database.update_sync_history_push_status(batch_id, 'pending')
get_database().update_sync_history_push_status(batch_id, 'pending')
threading.Thread(
target=_push_playlist_to_server,
args=(batch_id, batch),
@ -28988,6 +28991,43 @@ 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:
@ -29377,6 +29417,12 @@ 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
@ -31102,6 +31148,8 @@ 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(),
@ -31524,13 +31572,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
@ -31574,6 +31635,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:
@ -31588,6 +31729,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')),
@ -31634,9 +31776,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)
@ -32138,7 +32287,7 @@ def _check_batch_completion_v2(batch_id):
'listenbrainz_', 'beatport_',
)
if playlist_id and playlist_id.startswith(_push_prefixes):
database.update_sync_history_push_status(batch_id, 'pending')
get_database().update_sync_history_push_status(batch_id, 'pending')
threading.Thread(
target=_push_playlist_to_server,
args=(batch_id, batch),
@ -33425,16 +33574,6 @@ 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:
@ -33519,7 +33658,7 @@ def start_missing_tracks_process(playlist_id):
if '_original_index' not in track:
track['_original_index'] = i
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, tracks)
_submit_or_queue_batch(batch_id, playlist_id, tracks)
return jsonify({
"success": True,
@ -33554,6 +33693,8 @@ 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(),
@ -44122,7 +44263,7 @@ def get_discover_synced_playlists():
try:
with database._get_connection() as conn:
pool_count = conn.execute(
"SELECT COUNT(*) FROM discovery_pool WHERE source = ?", (active_source,)
"SELECT COUNT(*) FROM discovery_pool WHERE source = ? AND profile_id = ?", (active_source, pid)
).fetchone()[0]
except Exception:
pool_count = 0

View file

@ -9077,78 +9077,104 @@ 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
? `Last synced ${timeAgo(playlist.last_synced)}`
? `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-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-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-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">
<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>
<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">
<input type="checkbox" ${playlist.auto_update ? 'checked' : ''} ${isEmpty ? 'disabled' : ''}
onchange="toggleDiscoverAutoUpdate('${playlist.type}', this.checked)">
<span class="discover-sync-toggle-slider"></span>
</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>
</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>
<span class="discover-sync-toggle-slider"></span>
</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>
</span>
</label>
</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>
`;
// Make the icon + info area clickable to view tracks
// 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.
if (!isEmpty) {
const clickArea = card.querySelector('.discover-sync-card-info');
const iconArea = card.querySelector('.discover-sync-card-icon');
[clickArea, iconArea].forEach(el => {
const openTracks = () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon);
card.querySelectorAll('.discover-sync-card-top, .discover-sync-card-body').forEach(el => {
el.style.cursor = 'pointer';
el.addEventListener('click', () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon));
el.addEventListener('click', openTracks);
});
}
container.appendChild(card);
grid.appendChild(card);
}
async function toggleDiscoverAutoUpdate(playlistType, enabled) {
@ -9173,15 +9199,10 @@ async function toggleDiscoverAutoUpdate(playlistType, enabled) {
const _discoverSyncQueue = [];
let _discoverSyncRunning = false;
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;
}
async function syncDiscoverPlaylistFromTab(playlistType, playlistName) {
// Serialize sync operations to avoid concurrent backend contention
return new Promise((resolve) => {
_discoverSyncQueue.push({ playlistType, playlistName, forceDownload, resolve });
_discoverSyncQueue.push({ playlistType, playlistName, resolve });
_processDiscoverSyncQueue();
});
}
@ -9189,9 +9210,9 @@ async function syncDiscoverPlaylistFromTab(playlistType, playlistName, forceDown
async function _processDiscoverSyncQueue() {
if (_discoverSyncRunning || _discoverSyncQueue.length === 0) return;
_discoverSyncRunning = true;
const { playlistType, playlistName, forceDownload, resolve } = _discoverSyncQueue.shift();
const { playlistType, playlistName, resolve } = _discoverSyncQueue.shift();
try {
await _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload);
await _doSyncDiscoverPlaylist(playlistType, playlistName);
} finally {
_discoverSyncRunning = false;
resolve();
@ -9199,7 +9220,7 @@ async function _processDiscoverSyncQueue() {
}
}
async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload) {
async function _doSyncDiscoverPlaylist(playlistType, playlistName) {
const btn = document.getElementById(`discover-sync-btn-${playlistType}`);
if (btn) {
btn.disabled = true;
@ -9253,7 +9274,6 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload
tracks: syncTracks,
playlist_name: playlistName
};
if (forceDownload) bodyPayload.force_download_all = true;
const batchResponse = await fetch(`/api/playlists/${virtualPlaylistId}/start-missing-process`, {
method: 'POST',
@ -9263,8 +9283,7 @@ async function _doSyncDiscoverPlaylist(playlistType, playlistName, forceDownload
const result = await batchResponse.json();
if (result.success) {
const forceLabel = forceDownload ? ' (force download)' : '';
showToast(`Downloading ${playlistName}${forceLabel} (${syncTracks.length} tracks)...`, 'info');
showToast(`Downloading ${playlistName} (${syncTracks.length} tracks)...`, 'info');
const card = document.getElementById(`discover-sync-card-${playlistType}`);
if (card) {
const statusEl = card.querySelector('.discover-sync-status');

View file

@ -2250,6 +2250,8 @@ 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) {
// 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;
// Assign colors sequentially so no duplicates until all 16 are used
_batchColorMap[batchId] = _batchColorNext % _BATCH_COLOR_COUNT;
_batchColorNext++;
}
return _batchColorMap[batchId];
}
@ -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 = '';
@ -2507,8 +2511,11 @@ function _adlRenderBatchPanel() {
// Phase label with icon
let phaseText = '';
let phaseIcon = '';
if (batch.phase === 'analysis') {
phaseText = 'Analyzing...';
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...';
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
} else if (batch.phase === 'downloading') {
phaseText = `${batch.completed}/${total} tracks`;
@ -2607,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>`;
@ -2827,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) {
@ -2847,18 +2865,66 @@ 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';
const histDot = `<span class="adl-batch-history-dot" style="background:rgba(${dotColor}, 0.6)"></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>`;
}
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('');
}

View file

@ -56371,6 +56371,14 @@ 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 {
@ -56801,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;
@ -57180,29 +57188,66 @@ 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-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 {
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 ---- */
@ -59522,6 +59567,7 @@ 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 ───────────────────────────────────────── */
@ -59547,135 +59593,197 @@ 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 {
background: rgba(255, 255, 255, 0.04);
position: relative;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
margin: 3px 6px;
padding: 8px 14px;
border-radius: 14px;
padding: 14px 14px 12px;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.25s ease;
flex-direction: column;
gap: 12px;
min-height: 260px;
overflow: hidden;
transition: transform 0.25s ease, border-color 0.25s ease, background 0.25s ease;
}
.discover-sync-card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(167, 139, 250, 0.2);
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;
}
.discover-sync-card-icon {
font-size: 20px;
flex-shrink: 0;
width: 32px;
height: 32px;
font-size: 22px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
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;
}
.discover-sync-card-info {
.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;
flex: 1;
min-width: 0;
}
.discover-sync-card-name {
font-size: 13px;
font-weight: 600;
font-size: 15px;
font-weight: 700;
color: #fff;
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;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
}
.discover-sync-card-meta {
display: none;
}
color: rgba(255, 255, 255, 0.4);
}
.discover-sync-separator {
opacity: 0.4;
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
font-weight: 500;
margin-top: 2px;
}
.discover-sync-track-count {
color: rgba(255, 255, 255, 0.55);
color: rgba(255, 255, 255, 0.6);
}
.discover-sync-status {
font-weight: 500;
font-weight: 600;
padding: 2px 7px;
border-radius: 4px;
font-size: 10.5px;
letter-spacing: 0.2px;
}
.discover-sync-status.synced {
color: #22c55e;
color: #4ade80;
background: rgba(34, 197, 94, 0.12);
}
.discover-sync-status.syncing {
color: #facc15;
color: #fde047;
background: rgba(250, 204, 21, 0.14);
}
.discover-sync-status.not-synced {
color: rgba(255, 255, 255, 0.35);
color: rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.05);
}
.discover-sync-last-synced {
color: rgba(255, 255, 255, 0.35);
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
margin-top: 2px;
}
.discover-sync-card-actions {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.discover-sync-toggle-wrapper {
/* ── 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 {
display: flex;
align-items: center;
gap: 4px;
justify-content: space-between;
gap: 10px;
cursor: pointer;
min-height: 22px;
}
.discover-sync-toggle-row.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.discover-sync-toggle-label {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.4);
font-size: 11.5px;
font-weight: 500;
color: rgba(255, 255, 255, 0.65);
white-space: nowrap;
}
.discover-sync-toggle {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
cursor: pointer;
width: 34px;
height: 18px;
flex-shrink: 0;
}
.discover-sync-toggle input {
@ -59691,56 +59799,65 @@ 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: 22px;
border-radius: 18px;
transition: all 0.25s ease;
}
.discover-sync-toggle-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 3px;
bottom: 3px;
width: 14px;
height: 14px;
left: 2px;
bottom: 2px;
background: #fff;
border-radius: 50%;
transition: all 0.25s ease;
}
.discover-sync-toggle input:checked + .discover-sync-toggle-slider {
background: #a78bfa;
background: hsla(var(--card-hue, 260), 75%, 60%, 1);
}
.discover-sync-toggle input:checked + .discover-sync-toggle-slider::before {
transform: translateX(18px);
transform: translateX(16px);
}
.discover-sync-btn {
padding: 8px 16px;
background: linear-gradient(135deg, #a78bfa, #7c3aed);
/* ── 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));
color: #fff;
border: none;
border-radius: 8px;
border: 1px solid hsla(var(--card-hue, 260), 70%, 65%, 0.35);
border-radius: 10px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: all 0.25s ease;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
margin-top: auto;
}
.discover-sync-btn:hover:not(:disabled) {
.discover-sync-card-action:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(167, 139, 250, 0.35);
filter: brightness(1.08);
box-shadow: 0 6px 18px hsla(var(--card-hue, 260), 70%, 50%, 0.35);
}
.discover-sync-btn:disabled {
opacity: 0.4;
.discover-sync-card-action:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
filter: grayscale(30%);
}
/* Empty-state styling */
/* ── Empty / highlight states ── */
.discover-sync-card-empty {
opacity: 0.55;
}
@ -59749,16 +59866,22 @@ 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: 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; }
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;
}
}
/* ── 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);
@ -59794,14 +59917,14 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
}
@media (max-width: 768px) {
.discover-sync-card {
flex-wrap: wrap;
.discover-sync-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.discover-sync-card-actions {
width: 100%;
justify-content: flex-end;
.discover-sync-card {
min-height: 240px;
padding: 12px;
}
}

View file

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