Compare commits
20 commits
055c2c25c5
...
e40eb213b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e40eb213b4 | ||
|
|
97b0ee960a | ||
|
|
150dd046df | ||
|
|
62797c68af | ||
|
|
016e8a4c16 | ||
|
|
038a0189a4 | ||
|
|
92077c9117 | ||
|
|
1f944841a3 | ||
|
|
a0eb289d63 | ||
|
|
ddae75f0f9 | ||
|
|
5706f32ba2 | ||
|
|
f79cb182d4 | ||
|
|
735a0ebe58 | ||
|
|
d2bd66c0ce | ||
|
|
11b74d7c14 | ||
|
|
9b3e65c3d8 | ||
|
|
332392f338 | ||
|
|
c3b9f97afc | ||
|
|
3c06e66f03 | ||
|
|
99a95dcc91 |
7 changed files with 558 additions and 204 deletions
|
|
@ -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 = []
|
||||
|
||||
|
|
|
|||
183
web_server.py
183
web_server.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue