From dfdc6c6277a591561568eab023a227ffebea0780 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 25 May 2026 12:24:48 -0700
Subject: [PATCH] Restyle Auto-Sync manager and fix loading regressions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three problems wrapped into one pass on the Playlist Auto-Sync surface:
1. Visual: the manager modal had its own vibe (radial gradient, pill
tabs, sky-blue chrome) that didn't line up with the rest of the
app. Reworked the modal shell, KPI summary, live pipeline monitor,
tab bar, schedule board sidebar, and column cards to use the
standard SoulSync patterns — gradient `#1a1a1a → #121212`,
accent-tinted 1px border, 20px radius, underline tabs, dense dark
card pattern that Automations + Library pages already use. Modal
now uses near-full screen so there's room for the schedule board
without horizontal scroll pain. Run history cards followed the
same path: slim horizontal row mirroring `.automation-card` plus
an expanded detail that mirrors the Automations run-history modal
(stats-grid + facts row + result pills + log section).
2. Hang: the previous SQL fix for the run-history "in library" count
added `COLLATE NOCASE` on the join columns of `tracks` and
`artists`. SQLite can't use `idx_artists_name` or `idx_tracks_title`
when the comparison collation doesn't match the column collation,
so the join did a full table scan per mirrored playlist track.
~18s per playlist × 30 playlists = `/api/mirrored-playlists` hung
indefinitely and the modal stayed at "Loading schedule…" forever.
Switched the join back to case-sensitive equality (~6ms per
playlist, 3000× faster). Spotify names canonicalize to the same
form as library imports so the recall loss is in the rounding
error of pure case-only mismatches.
3. Slowness: even after the hang fix, each modal open spent ~1.5s
gathering per-playlist status counts. The endpoint looped
`get_mirrored_playlist_status_counts(playlist_id)` per row, which
opened a fresh SQLite connection + PRAGMA setup each time. Added
`get_all_mirrored_playlist_status_counts(profile_id)` which
returns counts for every mirrored playlist owned by the active
profile in 4 batched `GROUP BY` queries over a single connection.
Modal load dropped to ~280ms.
Also fixed: `tracks.artist` reference in `get_mirrored_playlist_status_counts`
that never worked since the schema went relational — the query threw
"no such column", got swallowed by the try/except, and the in-library
count silently defaulted to 0 on every playlist. Rewired to join
through `artists`.
`get_mirrored_playlist_status_counts` (single-playlist) kept for
callers that still want it, but the modal endpoint uses the batched
version.
---
database/music_database.py | 117 ++++-
web_server.py | 21 +-
webui/static/auto-sync.js | 221 +++++----
webui/static/helper.js | 4 +
webui/static/style.css | 914 +++++++++++++++++++------------------
5 files changed, 726 insertions(+), 551 deletions(-)
diff --git a/database/music_database.py b/database/music_database.py
index e27500e9..e84c21e8 100644
--- a/database/music_database.py
+++ b/database/music_database.py
@@ -12136,6 +12136,82 @@ class MusicDatabase:
logger.error(f"Error getting mirrored playlist discovery counts: {e}")
return (0, 0)
+ def get_all_mirrored_playlist_status_counts(self, profile_id: int = 1) -> dict:
+ """Return status counts for every mirrored playlist owned by the profile
+ in a single round-trip. Replaces N×4-query per-playlist loop on the
+ Auto-Sync modal load path. Result is `{playlist_id: {total, discovered,
+ wishlisted, in_library}}`."""
+ result: dict = {}
+ try:
+ with self._get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT mp.id as playlist_id
+ FROM mirrored_playlists mp
+ WHERE mp.profile_id = ?
+ """, (profile_id,))
+ for row in cursor.fetchall():
+ result[row['playlist_id']] = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0}
+
+ # Core counts: total + discovered, grouped per playlist
+ cursor.execute("""
+ SELECT mpt.playlist_id,
+ COUNT(*) as total,
+ SUM(CASE WHEN mpt.extra_data LIKE '%"discovered": true%' THEN 1 ELSE 0 END) as discovered
+ FROM mirrored_playlist_tracks mpt
+ JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id
+ WHERE mp.profile_id = ?
+ GROUP BY mpt.playlist_id
+ """, (profile_id,))
+ for row in cursor.fetchall():
+ pid = row['playlist_id']
+ if pid not in result:
+ result[pid] = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0}
+ result[pid]['total'] = row['total'] or 0
+ result[pid]['discovered'] = row['discovered'] or 0
+
+ # Wishlist counts in one shot
+ try:
+ cursor.execute("""
+ SELECT mpt.playlist_id, COUNT(*) as wishlisted
+ FROM mirrored_playlist_tracks mpt
+ JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id
+ WHERE mp.profile_id = ?
+ AND mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
+ AND EXISTS (SELECT 1 FROM wishlist_tracks wt
+ WHERE wt.spotify_track_id = mpt.source_track_id)
+ GROUP BY mpt.playlist_id
+ """, (profile_id,))
+ for row in cursor.fetchall():
+ pid = row['playlist_id']
+ if pid in result:
+ result[pid]['wishlisted'] = row['wishlisted'] or 0
+ except Exception as e:
+ logger.debug(f"Batch wishlist counts failed: {e}")
+
+ # In-library counts in one shot. Case-sensitive join so
+ # idx_artists_name + idx_tracks_title kick in.
+ try:
+ cursor.execute("""
+ SELECT mpt.playlist_id, COUNT(DISTINCT mpt.id) as in_library
+ FROM mirrored_playlist_tracks mpt
+ JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id
+ JOIN artists a ON a.name = mpt.artist_name
+ JOIN tracks t ON t.artist_id = a.id
+ AND t.title = mpt.track_name
+ WHERE mp.profile_id = ?
+ GROUP BY mpt.playlist_id
+ """, (profile_id,))
+ for row in cursor.fetchall():
+ pid = row['playlist_id']
+ if pid in result:
+ result[pid]['in_library'] = row['in_library'] or 0
+ except Exception as e:
+ logger.debug(f"Batch library counts failed: {e}")
+ except Exception as e:
+ logger.error(f"Error getting batch mirrored playlist status counts: {e}")
+ return result
+
def get_mirrored_playlist_status_counts(self, playlist_id: int) -> dict:
"""Return discovery, wishlisted, and downloaded counts for a mirrored playlist.
Discovery counts are critical (same as old method). Library/wishlist counts are
@@ -12157,26 +12233,39 @@ class MusicDatabase:
)
result['discovered'] = cursor.fetchone()['discovered']
- # Best-effort extras — won't break if tracks table has issues
+ # Best-effort extras — won't break if tracks table has issues.
+ # Wishlisted: indexed via wishlist_tracks.spotify_track_id.
try:
cursor.execute("""
- SELECT
- SUM(CASE WHEN mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
- AND EXISTS (SELECT 1 FROM wishlist_tracks wt
- WHERE wt.spotify_track_id = mpt.source_track_id)
- THEN 1 ELSE 0 END) as wishlisted,
- SUM(CASE WHEN EXISTS (SELECT 1 FROM tracks t
- WHERE t.title = mpt.track_name COLLATE NOCASE
- AND t.artist = mpt.artist_name COLLATE NOCASE)
- THEN 1 ELSE 0 END) as in_library
+ SELECT COUNT(*) as wishlisted
FROM mirrored_playlist_tracks mpt
WHERE mpt.playlist_id = ?
+ AND mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
+ AND EXISTS (SELECT 1 FROM wishlist_tracks wt
+ WHERE wt.spotify_track_id = mpt.source_track_id)
""", (playlist_id,))
- row = cursor.fetchone()
- result['wishlisted'] = row['wishlisted'] or 0
- result['in_library'] = row['in_library'] or 0
+ result['wishlisted'] = cursor.fetchone()['wishlisted'] or 0
except Exception as extra_err:
- logger.debug(f"Optional status counts failed for playlist {playlist_id}: {extra_err}")
+ logger.debug(f"Wishlist count failed for playlist {playlist_id}: {extra_err}")
+
+ # In-library: case-sensitive equality so SQLite can use
+ # `idx_artists_name` and `idx_tracks_title`. COLLATE NOCASE on
+ # the join columns prevents index usage and takes ~18s per
+ # playlist on a 300k-track library; the case-sensitive variant
+ # is ~6ms. Misses purely-case-different matches (rare — Spotify
+ # canonicalizes artist/track names that match library imports).
+ try:
+ cursor.execute("""
+ SELECT COUNT(DISTINCT mpt.id) as in_library
+ FROM mirrored_playlist_tracks mpt
+ JOIN artists a ON a.name = mpt.artist_name
+ JOIN tracks t ON t.artist_id = a.id
+ AND t.title = mpt.track_name
+ WHERE mpt.playlist_id = ?
+ """, (playlist_id,))
+ result['in_library'] = cursor.fetchone()['in_library'] or 0
+ except Exception as extra_err:
+ logger.debug(f"Library count failed for playlist {playlist_id}: {extra_err}")
except Exception as e:
logger.error(f"Error getting mirrored playlist status counts: {e}")
diff --git a/web_server.py b/web_server.py
index f50a9e18..fac6cd53 100644
--- a/web_server.py
+++ b/web_server.py
@@ -305,7 +305,20 @@ _STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
@app.context_processor
def _inject_static_cache_bust():
- return {'static_v': _STATIC_CACHE_BUST}
+ static_v = _STATIC_CACHE_BUST
+ if DEV_STATIC_NO_CACHE:
+ try:
+ static_dir = Path(app.static_folder)
+ mtimes = [
+ p.stat().st_mtime_ns
+ for p in static_dir.rglob('*')
+ if p.is_file() and p.suffix.lower() in {'.css', '.js'}
+ ]
+ if mtimes:
+ static_v = str(max(mtimes))
+ except Exception:
+ static_v = _STATIC_CACHE_BUST
+ return {'static_v': static_v}
@app.context_processor
@@ -32195,8 +32208,12 @@ def get_mirrored_playlists_endpoint():
database = get_database()
profile_id = get_current_profile_id()
playlists = database.get_mirrored_playlists(profile_id=profile_id)
+ # Single batched query instead of N per-playlist round-trips. Used to
+ # take ~50ms per playlist (new connection + 4 sub-queries) — at 30
+ # playlists that's 1.5s of modal load time just for status counts.
+ batch_counts = database.get_all_mirrored_playlist_status_counts(profile_id=profile_id)
for pl in playlists:
- counts = database.get_mirrored_playlist_status_counts(pl['id'])
+ counts = batch_counts.get(pl['id'], {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0})
pl['discovered_count'] = counts['discovered']
pl['total_count'] = counts['total']
pl['wishlisted_count'] = counts['wishlisted']
diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js
index 9d2725e5..eed7fd34 100644
--- a/webui/static/auto-sync.js
+++ b/webui/static/auto-sync.js
@@ -194,7 +194,7 @@ async function refreshAutoSyncScheduleModal() {
-
Loading run history...
+
+
Preparing run history...
`;
}
@@ -448,9 +448,11 @@ function renderAutoSyncHistoryPanel(history, total) {
function populateAutoSyncHistoryList(root = document) {
const list = root.querySelector('.auto-sync-history-list');
if (!list) return;
- const history = _autoSyncScheduleState.runHistory || [];
+ const history = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : [];
const total = _autoSyncScheduleState.runHistoryTotal || 0;
list.innerHTML = '';
+ list.dataset.renderer = 'dom-cards';
+ list.dataset.renderedCount = '0';
history.forEach((entry, index) => {
try {
list.appendChild(createAutoSyncHistoryEntryElement(entry, index));
@@ -458,6 +460,11 @@ function populateAutoSyncHistoryList(root = document) {
list.appendChild(createAutoSyncHistoryErrorElement(entry, index, err));
}
});
+ const renderedCount = list.querySelectorAll('.auto-sync-history-entry').length;
+ list.dataset.renderedCount = String(renderedCount);
+ if (history.length && !renderedCount) {
+ list.appendChild(createAutoSyncHistoryListFallback(history.length));
+ }
if (total > history.length) {
const totalEl = document.createElement('div');
totalEl.className = 'auto-sync-history-total';
@@ -466,6 +473,17 @@ function populateAutoSyncHistoryList(root = document) {
}
}
+function createAutoSyncHistoryListFallback(count) {
+ const fallback = document.createElement('div');
+ fallback.className = 'auto-sync-history-empty';
+ const title = document.createElement('strong');
+ title.textContent = 'Run history could not render';
+ const detail = document.createElement('span');
+ detail.textContent = `${count} playlist pipeline run${count === 1 ? '' : 's'} loaded, but the card renderer did not complete. Refresh the page to reload the latest Auto-Sync assets.`;
+ fallback.append(title, detail);
+ return fallback;
+}
+
function createAutoSyncHistoryErrorElement(entry, index, err) {
const card = document.createElement('article');
card.className = 'auto-sync-history-entry auto-sync-history-entry-error';
@@ -508,10 +526,10 @@ function createAutoSyncHistoryEntryElement(entry, index = 0) {
const libraryDelta = autoSyncDelta(after.in_library_count, before.in_library_count);
const entryId = `auto-sync-history-${entry.id}`;
const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`;
- const summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status);
+ const triggerSource = entry.trigger_source || 'pipeline';
const card = document.createElement('article');
- card.className = 'auto-sync-history-entry';
+ card.className = `auto-sync-history-entry ${status === 'completed' || status === 'finished' ? '' : 'auto-sync-history-entry-' + status}`.trim();
card.id = `${entryId}-card`;
card.dataset.historyEntry = entryId;
@@ -523,42 +541,19 @@ function createAutoSyncHistoryEntryElement(entry, index = 0) {
row.setAttribute('aria-controls', entryId);
row.dataset.historyToggle = entryId;
- const head = document.createElement('div');
- head.className = 'auto-sync-history-card-head';
- const titleBlock = document.createElement('div');
- titleBlock.className = 'auto-sync-history-title-block';
- const titleRow = document.createElement('div');
- titleRow.className = 'auto-sync-history-title-row';
const dot = document.createElement('span');
dot.className = `auto-sync-card-status-dot ${autoSyncHistoryStatusClass(status)}`;
- const title = document.createElement('strong');
- title.textContent = playlistName;
- const statusBadge = document.createElement('span');
- statusBadge.className = `auto-sync-history-status ${status}`;
- statusBadge.textContent = autoSyncHistoryStatusLabel(status);
- titleRow.append(dot, title, statusBadge);
- const summaryEl = document.createElement('small');
- summaryEl.textContent = summary;
- titleBlock.append(titleRow, summaryEl);
- const meta = document.createElement('div');
- meta.className = 'auto-sync-history-meta';
- [started, duration, entry.trigger_source || 'pipeline'].filter(Boolean).forEach(value => {
- const span = document.createElement('span');
- span.textContent = value;
- meta.appendChild(span);
- });
- const expand = document.createElement('button');
- expand.type = 'button';
- expand.className = 'auto-sync-history-expand-label';
- expand.dataset.historyToggleButton = entryId;
- expand.textContent = 'View details';
- meta.appendChild(expand);
- head.append(titleBlock, meta);
+ const info = document.createElement('div');
+ info.className = 'auto-sync-history-info';
+
+ const name = document.createElement('div');
+ name.className = 'auto-sync-history-name';
+ name.textContent = playlistName;
const flow = document.createElement('div');
- flow.className = 'auto-sync-card-flow';
- autoSyncAppendFlowChip(flow, entry.trigger_source || 'pipeline', 'flow-trigger');
+ flow.className = 'auto-sync-history-flow';
+ autoSyncAppendFlowChip(flow, triggerSource, 'flow-trigger');
autoSyncAppendFlowArrow(flow);
autoSyncAppendFlowChip(flow, 'Refresh', 'flow-action');
autoSyncAppendFlowArrow(flow);
@@ -566,29 +561,60 @@ function createAutoSyncHistoryEntryElement(entry, index = 0) {
autoSyncAppendFlowArrow(flow);
autoSyncAppendFlowChip(flow, 'Sync + wishlist', 'flow-notify');
- const preview = document.createElement('div');
- preview.className = 'auto-sync-history-preview';
- [
- ['Tracks', before.track_count, after.track_count, trackDelta],
- ['Discovered', before.discovered_count, after.discovered_count, discoveredDelta],
- ['Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta],
- ['Library', before.in_library_count, after.in_library_count, libraryDelta],
- ].forEach(([label, beforeValue, afterValue, delta]) => {
- const pill = document.createElement('span');
- pill.textContent = autoSyncHistoryPreviewText(label, beforeValue, afterValue, delta);
- preview.appendChild(pill);
- });
+ const metaRow = document.createElement('div');
+ metaRow.className = 'auto-sync-history-meta-inline';
+ const statusBadge = document.createElement('span');
+ statusBadge.className = `auto-sync-history-status ${status}`;
+ statusBadge.textContent = autoSyncHistoryStatusLabel(status);
+ metaRow.appendChild(statusBadge);
+ if (started) {
+ const t = document.createElement('span');
+ t.className = 'auto-sync-history-time';
+ t.textContent = started;
+ metaRow.appendChild(t);
+ }
+ if (duration) {
+ const d = document.createElement('span');
+ d.className = 'auto-sync-history-duration';
+ d.textContent = duration;
+ metaRow.appendChild(d);
+ }
+ const trackChip = document.createElement('span');
+ const trackClass = trackDelta > 0 ? 'pos' : trackDelta < 0 ? 'neg' : 'zero';
+ trackChip.className = `auto-sync-history-delta ${trackClass}`;
+ trackChip.textContent = autoSyncDeltaLabel(after.track_count, trackDelta, 'tracks');
+ metaRow.appendChild(trackChip);
+
+ info.append(name, flow, metaRow);
+
+ const actions = document.createElement('div');
+ actions.className = 'auto-sync-history-actions';
+ const expand = document.createElement('button');
+ expand.type = 'button';
+ expand.className = 'auto-sync-history-expand-btn';
+ expand.dataset.historyToggleButton = entryId;
+ expand.setAttribute('aria-label', 'Toggle details');
+ expand.innerHTML = '
▾';
+ actions.appendChild(expand);
+
+ row.append(dot, info, actions);
const detail = document.createElement('div');
detail.id = entryId;
detail.className = 'auto-sync-history-detail';
detail.innerHTML = autoSyncHistoryDetailHtml(entry, before, after, result, { trackDelta, discoveredDelta, wishlistDelta, libraryDelta });
- row.append(head, flow, preview);
card.append(row, detail);
return card;
}
+function autoSyncDeltaLabel(after, delta, unit) {
+ const a = parseInt(after, 10) || 0;
+ if (!delta) return `${a} ${unit}`;
+ const sign = delta > 0 ? '+' : '';
+ return `${a} ${unit} (${sign}${delta})`;
+}
+
function autoSyncAppendFlowChip(parent, text, className) {
const span = document.createElement('span');
span.className = className;
@@ -730,53 +756,66 @@ function autoSyncHistoryResultPill(label, value) {
}
function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) {
- const resultPills = [
- autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed),
- autoSyncHistoryResultPill('Discovered', result.tracks_discovered),
- autoSyncHistoryResultPill('Synced', result.tracks_synced),
- autoSyncHistoryResultPill('Skipped', result.sync_skipped),
- autoSyncHistoryResultPill('Wishlisted', result.wishlist_queued),
- autoSyncHistoryResultPill('Duration', result.duration_seconds ? autoSyncDurationLabel(result.duration_seconds) : ''),
- result.error ? `
${_esc(result.error)}` : '',
- ].filter(Boolean).join('');
- const timeline = [
+ const stats = [
+ ['Tracks', before.track_count, after.track_count, deltas.trackDelta],
+ ['Discovered', before.discovered_count, after.discovered_count, deltas.discoveredDelta],
+ ['Wishlisted', before.wishlisted_count, after.wishlisted_count, deltas.wishlistDelta],
+ ['In library', before.in_library_count, after.in_library_count, deltas.libraryDelta],
+ ];
+ const statsHtml = stats.map(([label, b, a, d]) => autoSyncHistoryStatCardHtml(label, b, a, d)).join('');
+ const factsHtml = [
['Started', autoSyncFormatDateTime(entry.started_at)],
['Finished', autoSyncFormatDateTime(entry.finished_at)],
- ['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : 'Not recorded'],
- ['Trigger', entry.trigger_source || 'pipeline'],
- ['Source', entry.source || after.source || before.source || 'Unknown'],
- ['Playlist ID', entry.playlist_id || after.playlist_id || before.playlist_id || 'Unknown'],
- ];
+ ['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : '—'],
+ ['Source', entry.source || after.source || before.source || '—'],
+ ].map(([k, v]) => `
${_esc(k)}${_esc(autoSyncValueLabel(v))}
`).join('');
+ const resultPills = [
+ ['Refreshed', result.playlists_refreshed],
+ ['Discovered', result.tracks_discovered],
+ ['Synced', result.tracks_synced],
+ ['Skipped', result.sync_skipped],
+ ['Wishlisted', result.wishlist_queued],
+ ].filter(([, v]) => v !== undefined && v !== null && v !== '')
+ .map(([k, v]) => `
${_esc(k)}${_esc(String(v))}`).join('');
+ const errorBlock = result.error ? `
${_esc(result.error)}
` : '';
+ const logsHtml = autoSyncHistoryLogsCompactHtml(entry.log_lines);
return `
-
-
- Run Summary
-
- ${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, deltas.trackDelta)}
- ${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, deltas.discoveredDelta)}
- ${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, deltas.wishlistDelta)}
- ${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, deltas.libraryDelta)}
-
-
- ${resultPills || 'No detailed result payload recorded for this run.'}
-
-
-
- Timeline
-
- ${timeline.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')}
-
-
-
-
- ${autoSyncHistorySnapshotHtml('Before refresh', before)}
- ${autoSyncHistorySnapshotHtml('After pipeline', after)}
-
- ${autoSyncHistoryObjectHtml('Result payload', result, { skipPrivate: true })}
- ${autoSyncHistoryLogsHtml(entry.log_lines)}
+
${statsHtml}
+
${factsHtml}
+ ${resultPills ? `
${resultPills}
` : ''}
+ ${errorBlock}
+ ${logsHtml}
`;
}
+function autoSyncHistoryStatCardHtml(label, before, after, delta) {
+ const a = parseInt(after, 10) || 0;
+ const b = parseInt(before, 10) || 0;
+ const deltaClass = delta > 0 ? 'pos' : delta < 0 ? 'neg' : 'zero';
+ const deltaText = delta ? `${delta > 0 ? '+' : ''}${delta}` : '';
+ return `
+
+
${_esc(label)}
+
+ ${b}
+ →
+ ${a}
+ ${deltaText ? `${deltaText}` : ''}
+
+
+ `;
+}
+
+function autoSyncHistoryLogsCompactHtml(logLines) {
+ if (!Array.isArray(logLines) || !logLines.length) return '';
+ const lines = logLines.slice(-20).map(line => {
+ const text = typeof line === 'string' ? line : (line.message || line.log_line || JSON.stringify(line));
+ const type = typeof line === 'object' ? (line.type || line.log_type || 'info') : 'info';
+ return `
${_esc(text)}
`;
+ }).join('');
+ return `
${lines}
`;
+}
+
function autoSyncHistoryFactHtml(label, value) {
return `
diff --git a/webui/static/helper.js b/webui/static/helper.js
index c6859090..8a8c621f 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3415,6 +3415,10 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.6.2': [
{ date: 'May 24, 2026 — 2.6.2 release' },
+ { title: 'Auto-Sync manager redesigned to match the rest of the app', desc: 'the Auto-Sync modal got a top-to-bottom restyle so it stops feeling like it lives in a different app. modal shell now uses the standard SoulSync gradient + accent-tinted border + 20px radius, the KPI summary became inset stat tiles, the live pipeline monitor is auto-fill instead of a fixed 4-col grid, tabs are now underline-style instead of pill buttons, and the schedule board sidebar/columns use the dense dark-card pattern that Automations + Library pages already use. modal also fills more of the screen since there was a lot of unused real estate. run history cards got the same treatment — slim horizontal row matching `.automation-card` (status dot + name + flow chips + meta row), and the expanded panel uses the same stats-grid / log-section structure as the Automations run-history modal.' },
+ { title: 'Fix: Auto-Sync manager hung forever on "Loading schedule..."', desc: 'a regression while wiring up the new "in library" count: the join used `COLLATE NOCASE` on `tracks.title` and `artists.name`, which can\'t use the indexes those columns have. on a 300k-track library each playlist took ~18s. with 30 mirrored playlists the modal\'s `/api/mirrored-playlists` call would never come back. switched to case-sensitive equality so SQLite uses `idx_artists_name` + `idx_tracks_title` (6ms per playlist). misses pure-case-different matches but Spotify names are canonical against library imports, so it\'s a rounding-error tradeoff for the 3000× speedup.' },
+ { title: 'Fix: Auto-Sync run history `in library` always showed 0', desc: 'the SQL query was referencing `tracks.artist` — a column that hasn\'t existed since the schema went relational. `tracks` has `artist_id` (FK to `artists`), so the query threw "no such column", got swallowed by the surrounding try/except, and the count silently defaulted to 0 on every playlist. rewired the join through `artists` so the count actually reflects how many mirrored tracks already live in your library.' },
+ { title: 'Auto-Sync modal opens faster (1.5s → ~280ms)', desc: 'opening the Auto-Sync manager used to spend ~1.5s in `/api/mirrored-playlists` because each mirrored playlist row triggered its own `get_mirrored_playlist_status_counts` call, and each of those opened a fresh SQLite connection with PRAGMA setup. new `get_all_mirrored_playlist_status_counts(profile_id)` does the totals / discovered / wishlisted / in-library counts across every playlist for the active profile in 4 batched `GROUP BY` queries over one connection. about 5× faster on a 30-playlist account.' },
{ title: 'Playlist sync is way faster on partial-match playlists', desc: 'syncing a playlist where most tracks weren\'t in the library used to take forever — a 30-track playlist with only 9 matches was burning 4+ minutes because every unmatched track ran the full title-variation × artist-variation SQL grid against the tracks table (~30 SQL queries per missed track). cache only covered matches, so misses re-paid the cost every sync. sync now uses a per-artist track pool that fills in lazily — only tracks that miss the sync match cache trigger a one-time fetch of their artist\'s library tracks, and later misses for the same artist reuse the in-memory list. playlists where every track is already cached pay zero pool cost (no upfront delay). benefits every sync entry point — manual, auto-sync, the playlist_pipeline automation action, and the Sync All button.' },
{ title: 'Fix: Auto-Sync "next in 8h" timezone bug', desc: 'scheduled Auto-Sync playlists were all showing "next in 8h" regardless of the interval — Every 1 hour, Every 1 day, anything. backend stores next-run as a naive UTC string and the frontend was parsing it as local time, which on Pacific time offset the displayed countdown by ~8 hours. Auto-Sync now uses the existing UTC-aware parser the rest of the Automations page already uses. as a separate correctness fix, the automation update endpoint also now blanks the stored next-run whenever the trigger type or trigger config changes, so the engine recomputes from scratch instead of preserving a leftover timestamp from the previous schedule.' },
{ title: 'Auto-Sync modal restyled to your accent color', desc: 'the Playlist Auto-Sync manager now picks up your chosen accent (the same one used everywhere else in the app) instead of the hardcoded sky-blue palette it shipped with. tabs, drop zones, scheduled-playlist chips, scrollbars, and the modal glow all follow the accent theme. drop targets also light up clearly while dragging.' },
diff --git a/webui/static/style.css b/webui/static/style.css
index fef007dc..afc4ed5d 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -11202,16 +11202,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.auto-sync-modal {
- width: min(1500px, calc(100vw - 40px));
- height: min(940px, calc(100vh - 28px));
- background:
- radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08) 0%, transparent 60%),
- rgba(17, 19, 27, 0.98);
+ width: calc(100vw - 96px);
+ height: calc(100vh - 64px);
+ background: linear-gradient(135deg, #1a1a1a 0%, #121212 100%);
border: 1px solid rgba(var(--accent-rgb), 0.2);
- border-radius: 10px;
+ border-radius: 20px;
box-shadow:
- 0 28px 80px rgba(0, 0, 0, 0.5),
- 0 0 60px rgba(var(--accent-rgb), 0.08);
+ 0 25px 80px rgba(0, 0, 0, 0.7),
+ 0 0 0 1px rgba(var(--accent-rgb), 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -11222,87 +11220,92 @@ body.helper-mode-active #dashboard-activity-feed:hover {
align-items: flex-start;
justify-content: space-between;
gap: 20px;
- padding: 22px 26px 18px;
+ padding: 24px 28px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
+ position: relative;
}
.auto-sync-eyebrow {
- margin-bottom: 6px;
- color: rgb(var(--accent-light-rgb));
- font-size: 11px;
- font-weight: 800;
+ margin-bottom: 4px;
+ color: rgba(var(--accent-rgb), 0.85);
+ font-size: 10px;
+ font-weight: 700;
text-transform: uppercase;
- letter-spacing: 0.06em;
+ letter-spacing: 0.12em;
}
.auto-sync-header h3 {
- margin: 0 0 6px;
- color: rgba(255, 255, 255, 0.92);
- font-size: 21px;
+ margin: 0 0 4px;
+ color: #fff;
+ font-size: 22px;
font-weight: 700;
+ letter-spacing: -0.01em;
}
.auto-sync-header p {
margin: 0;
- color: rgba(255, 255, 255, 0.48);
+ color: rgba(255, 255, 255, 0.55);
font-size: 13px;
+ line-height: 1.45;
}
.auto-sync-close {
width: 32px;
height: 32px;
- border: 0;
- border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
- color: rgba(255, 255, 255, 0.65);
+ color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 22px;
line-height: 1;
}
.auto-sync-close:hover {
- background: rgba(255, 255, 255, 0.12);
- color: #fff;
+ background: rgba(239, 68, 68, 0.2);
+ border-color: rgba(239, 68, 68, 0.4);
+ color: #ef4444;
}
.auto-sync-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 1px;
- background: rgba(255, 255, 255, 0.08);
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ gap: 10px;
+ padding: 14px 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.auto-sync-summary div {
- padding: 12px 20px;
- background: rgba(255, 255, 255, 0.025);
+ padding: 10px 14px;
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 10px;
}
.auto-sync-summary span {
display: block;
- color: rgba(255, 255, 255, 0.92);
- font-size: 20px;
- font-weight: 800;
+ color: #fff;
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
}
.auto-sync-summary small {
display: block;
margin-top: 2px;
- color: rgba(255, 255, 255, 0.42);
- font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 10px;
font-weight: 700;
text-transform: uppercase;
+ letter-spacing: 0.3px;
}
.auto-sync-monitor {
flex-shrink: 0;
- padding: 12px 18px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
- background:
- radial-gradient(circle at 14% 0%, rgba(var(--accent-rgb), 0.14), transparent 34%),
- rgba(255, 255, 255, 0.018);
+ padding: 12px 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.auto-sync-monitor-head {
@@ -11325,59 +11328,62 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-monitor-kicker {
margin-bottom: 3px;
- color: rgb(var(--accent-light-rgb));
- font-size: 10px;
- font-weight: 800;
- letter-spacing: 0.06em;
+ color: rgba(var(--accent-rgb), 0.85);
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
text-transform: uppercase;
}
.auto-sync-monitor-head strong {
- color: rgba(255, 255, 255, 0.9);
- font-size: 14px;
+ color: rgba(255, 255, 255, 0.92);
+ font-size: 13px;
+ font-weight: 600;
}
.auto-sync-monitor-head small {
margin-top: 2px;
- color: rgba(255, 255, 255, 0.44);
- font-size: 12px;
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 11px;
}
.auto-sync-monitor-head button,
.auto-sync-board-intro button,
.auto-sync-history-intro button {
height: 30px;
- padding: 0 12px;
+ padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 6px;
- background: rgba(255, 255, 255, 0.05);
- color: rgba(255, 255, 255, 0.7);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.65);
cursor: pointer;
- font-size: 12px;
- font-weight: 700;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.2px;
+ transition: all 0.2s ease;
}
.auto-sync-monitor-head button:hover,
.auto-sync-board-intro button:hover,
.auto-sync-history-intro button:hover {
- background: rgba(var(--accent-rgb), 0.14);
- border-color: rgba(var(--accent-rgb), 0.32);
+ background: rgba(var(--accent-rgb), 0.18);
+ border-color: rgba(var(--accent-rgb), 0.4);
color: rgb(var(--accent-light-rgb));
}
.auto-sync-monitor-list {
display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 10px;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 8px;
margin-top: 10px;
}
.auto-sync-monitor-card,
.auto-sync-monitor-empty {
min-width: 0;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.035);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 10px;
+ background: rgba(22, 22, 22, 0.95);
}
.auto-sync-monitor-card {
@@ -11489,12 +11495,12 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-monitor-empty {
display: flex;
- align-items: baseline;
+ align-items: center;
gap: 10px;
width: fit-content;
max-width: 100%;
margin-top: 8px;
- padding: 8px 10px;
+ padding: 8px 12px;
}
.auto-sync-monitor-empty span,
@@ -11503,15 +11509,15 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.auto-sync-monitor-empty span {
- color: rgba(255, 255, 255, 0.72);
- font-size: 13px;
- font-weight: 800;
+ color: rgba(255, 255, 255, 0.65);
+ font-size: 12px;
+ font-weight: 600;
}
.auto-sync-monitor-empty small {
margin-top: 0;
- color: rgba(255, 255, 255, 0.38);
- font-size: 12px;
+ color: rgba(255, 255, 255, 0.35);
+ font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -11519,31 +11525,35 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-tabs {
display: flex;
- gap: 8px;
- padding: 12px 18px 10px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.018);
+ gap: 0;
+ padding: 0 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.auto-sync-tabs button {
- height: 32px;
- padding: 0 14px;
- border: 1px solid rgba(255, 255, 255, 0.09);
- border-radius: 6px;
- background: rgba(255, 255, 255, 0.04);
- color: rgba(255, 255, 255, 0.58);
+ height: 40px;
+ padding: 0 18px;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 12px;
- font-weight: 700;
+ font-weight: 600;
+ letter-spacing: 0.2px;
+ transition: color 0.2s ease, border-color 0.2s ease;
+ position: relative;
+ top: 1px;
+}
+
+.auto-sync-tabs button:hover {
+ color: rgba(255, 255, 255, 0.85);
}
-.auto-sync-tabs button:hover,
.auto-sync-tabs button.active {
- border-color: rgba(var(--accent-rgb), 0.4);
- background: rgba(var(--accent-rgb), 0.14);
- color: rgb(var(--accent-neon-rgb));
- box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.08), 0 2px 10px rgba(var(--accent-rgb), 0.12);
+ color: rgb(var(--accent-light-rgb));
+ border-bottom-color: rgb(var(--accent-rgb));
}
.auto-sync-tab-panel {
@@ -11564,9 +11574,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
align-items: center;
justify-content: space-between;
gap: 16px;
- padding: 14px 18px;
- border-bottom: 1px solid rgba(var(--accent-rgb), 0.16);
- background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.08) 0%, rgba(var(--accent-rgb), 0.03) 60%, transparent 100%);
+ padding: 14px 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
flex-shrink: 0;
}
@@ -11574,8 +11583,9 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-automation-intro strong,
.auto-sync-history-intro strong {
display: block;
- color: rgba(255, 255, 255, 0.86);
+ color: rgba(255, 255, 255, 0.85);
font-size: 13px;
+ font-weight: 600;
}
.auto-sync-board-intro span,
@@ -11583,31 +11593,33 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-history-intro span {
display: block;
margin-top: 2px;
- color: rgba(255, 255, 255, 0.46);
- font-size: 12px;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 11px;
+ line-height: 1.4;
}
.auto-sync-body {
min-height: 0;
flex: 1;
display: grid;
- grid-template-columns: 300px minmax(0, 1fr);
+ grid-template-columns: 340px minmax(0, 1fr);
}
.auto-sync-sidebar {
min-height: 0;
- border-right: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.025);
+ border-right: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
}
.auto-sync-sidebar-title {
- padding: 16px 18px 10px;
- color: rgba(255, 255, 255, 0.72);
- font-size: 12px;
+ padding: 14px 18px 8px;
+ color: rgba(255, 255, 255, 0.55);
+ font-size: 10px;
font-weight: 700;
text-transform: uppercase;
+ letter-spacing: 0.3px;
}
.auto-sync-source-list {
@@ -11617,61 +11629,88 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.auto-sync-source-group {
- margin-bottom: 16px;
+ margin-bottom: 14px;
}
.auto-sync-source-title {
- padding: 8px 6px;
- color: rgba(255, 255, 255, 0.42);
- font-size: 12px;
+ padding: 6px 4px 4px;
+ color: rgba(255, 255, 255, 0.38);
+ font-size: 10px;
font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
}
.auto-sync-playlist,
.auto-sync-scheduled-card {
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 7px;
- background: rgba(255, 255, 255, 0.045);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 10px;
+ background: rgba(22, 22, 22, 0.95);
cursor: grab;
+ transition: border-color 0.2s ease, background 0.2s ease;
}
.auto-sync-playlist {
- padding: 11px 10px;
- margin-bottom: 8px;
+ padding: 10px 12px;
+ margin-bottom: 6px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.auto-sync-playlist::before {
+ content: '';
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.18);
+ flex-shrink: 0;
+}
+
+.auto-sync-playlist.scheduled::before {
+ background: rgb(var(--accent-rgb));
+ box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.5);
+}
+
+.auto-sync-playlist > div {
+ min-width: 0;
+ flex: 1;
}
.auto-sync-playlist:hover,
.auto-sync-scheduled-card:hover {
- border-color: rgba(var(--accent-rgb), 0.4);
- background: rgba(var(--accent-rgb), 0.1);
- box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.12);
+ border-color: rgba(var(--accent-rgb), 0.25);
+ background: rgba(28, 28, 28, 0.98);
}
.auto-sync-playlist.scheduled {
- border-color: rgba(var(--accent-rgb), 0.3);
- background: rgba(var(--accent-rgb), 0.05);
+ border-color: rgba(var(--accent-rgb), 0.22);
}
.auto-sync-playlist.unavailable {
cursor: default;
- opacity: 0.58;
+ opacity: 0.5;
+}
+
+.auto-sync-playlist.unavailable::before {
+ background: rgba(255, 255, 255, 0.1);
}
.auto-sync-playlist.unavailable:hover {
- border-color: rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.045);
+ border-color: rgba(255, 255, 255, 0.07);
+ background: rgba(22, 22, 22, 0.95);
}
.auto-sync-source-group-disabled {
- padding-top: 8px;
- border-top: 1px solid rgba(255, 255, 255, 0.08);
+ padding-top: 10px;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.auto-sync-playlist-name,
.auto-sync-scheduled-name {
- color: rgba(255, 255, 255, 0.86);
- font-size: 13px;
- font-weight: 700;
+ color: #fff;
+ font-size: 12px;
+ font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -11680,14 +11719,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-scheduled-name {
white-space: normal;
overflow-wrap: anywhere;
- line-height: 1.25;
+ line-height: 1.3;
}
.auto-sync-playlist-meta,
.auto-sync-scheduled-meta {
- margin-top: 4px;
- color: rgba(255, 255, 255, 0.42);
- font-size: 11px;
+ margin-top: 2px;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 10px;
}
.auto-sync-scheduled-meta {
@@ -11698,32 +11737,31 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-scheduled-timing {
display: flex;
flex-wrap: wrap;
- gap: 5px;
+ gap: 4px;
margin-top: 8px;
}
.auto-sync-scheduled-timing span,
.auto-sync-scheduled-timing small {
max-width: 100%;
- padding: 3px 6px;
- border-radius: 999px;
+ padding: 2px 8px;
+ border-radius: 10px;
font-size: 10px;
- font-weight: 800;
- line-height: 1;
+ font-weight: 600;
+ line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-sync-scheduled-timing span {
- background: rgba(var(--accent-rgb), 0.18);
+ background: rgba(var(--accent-rgb), 0.12);
color: rgb(var(--accent-light-rgb));
- border: 1px solid rgba(var(--accent-rgb), 0.25);
}
.auto-sync-scheduled-timing small {
- background: rgba(255, 255, 255, 0.06);
- color: rgba(255, 255, 255, 0.48);
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.45);
}
.auto-sync-board {
@@ -11731,46 +11769,49 @@ body.helper-mode-active #dashboard-activity-feed:hover {
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
- padding: 26px;
+ padding: 20px;
display: grid;
- grid-template-columns: repeat(10, minmax(240px, 1fr));
+ grid-template-columns: repeat(10, minmax(220px, 1fr));
grid-auto-rows: minmax(0, 1fr);
- gap: 16px;
+ gap: 12px;
}
.auto-sync-column {
min-height: 0;
max-height: 100%;
overflow: hidden;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.025);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 12px;
+ background: rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
- transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
+ transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.auto-sync-column.drag-over {
- border-color: rgba(var(--accent-rgb), 0.6);
- background: rgba(var(--accent-rgb), 0.08);
- box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.3), 0 6px 20px rgba(var(--accent-rgb), 0.15);
+ border-color: rgba(var(--accent-rgb), 0.5);
+ background: rgba(var(--accent-rgb), 0.06);
+ box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.3), 0 6px 18px rgba(var(--accent-rgb), 0.12);
}
.auto-sync-column-head {
display: flex;
align-items: center;
justify-content: space-between;
- padding: 14px 16px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.07);
- color: rgba(255, 255, 255, 0.86);
- font-weight: 800;
+ padding: 12px 14px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.85);
+ font-size: 13px;
+ font-weight: 700;
flex-shrink: 0;
}
.auto-sync-column-head small {
- color: rgb(var(--accent-light-rgb));
- font-size: 11px;
- font-weight: 700;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
}
.auto-sync-column-list {
@@ -11810,15 +11851,15 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-empty,
.auto-sync-loading,
.auto-sync-error {
- color: rgba(255, 255, 255, 0.38);
- font-size: 13px;
+ color: rgba(255, 255, 255, 0.35);
+ font-size: 12px;
text-align: center;
}
.auto-sync-drop-hint {
- border: 1px dashed rgba(255, 255, 255, 0.12);
- border-radius: 7px;
- padding: 18px 10px;
+ border: 1px dashed rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ padding: 20px 12px;
}
.auto-sync-drop-hint strong,
@@ -11827,13 +11868,16 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.auto-sync-drop-hint strong {
- color: rgba(255, 255, 255, 0.52);
- font-size: 12px;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
}
.auto-sync-drop-hint span {
- margin-top: 3px;
- color: rgba(255, 255, 255, 0.32);
+ margin-top: 4px;
+ color: rgba(255, 255, 255, 0.3);
font-size: 11px;
}
@@ -11846,12 +11890,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
box-sizing: border-box;
width: 100%;
min-width: 0;
- padding: 14px;
- margin-bottom: 12px;
+ padding: 12px;
+ margin-bottom: 8px;
display: flex;
flex-direction: column;
- gap: 11px;
- overflow: hidden;
+ gap: 10px;
}
.auto-sync-scheduled-card.disabled {
@@ -11873,26 +11916,31 @@ body.helper-mode-active #dashboard-activity-feed:hover {
}
.auto-sync-scheduled-card button {
- width: 24px;
- height: 24px;
- border: 0;
- border-radius: 5px;
- background: rgba(255, 255, 255, 0.06);
- color: rgba(255, 255, 255, 0.5);
+ width: 26px;
+ height: 26px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.45);
cursor: pointer;
flex-shrink: 0;
+ font-size: 12px;
+ transition: all 0.2s ease;
}
.auto-sync-scheduled-card button.run {
width: auto;
min-width: 0;
+ height: 26px;
flex: 1;
- padding: 0 9px;
+ padding: 0 10px;
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb), 0.12);
- border: 1px solid rgba(var(--accent-rgb), 0.22);
+ border: 1px solid rgba(var(--accent-rgb), 0.25);
font-size: 10px;
- font-weight: 800;
+ font-weight: 700;
+ letter-spacing: 0.3px;
+ text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -11911,7 +11959,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-scheduled-card button:not(.run):hover {
color: #ef4444;
- background: rgba(239, 68, 68, 0.12);
+ background: rgba(239, 68, 68, 0.2);
+ border-color: rgba(239, 68, 68, 0.4);
}
.auto-sync-automation-list {
@@ -12078,353 +12127,330 @@ body.helper-mode-active #dashboard-activity-feed:hover {
gap: 12px;
}
-.auto-sync-history-entry {
- display: block;
- border: 1px solid rgba(255, 255, 255, 0.07);
+.auto-sync-history-loading {
+ padding: 28px;
+ border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 12px;
- background: rgba(22, 22, 22, 0.95);
- overflow: hidden;
- box-shadow: none;
-}
-
-.auto-sync-history-entry.expanded {
- border-color: rgba(var(--accent-rgb), 0.26);
- background: rgba(26, 26, 26, 0.98);
-}
-
-.auto-sync-history-entry:empty {
- display: flex;
- align-items: center;
- padding: 16px;
-}
-
-.auto-sync-history-entry:empty::before {
- content: 'Run history entry unavailable. Refresh after the next playlist pipeline run.';
- color: rgba(255, 255, 255, 0.58);
- font-size: 12px;
+ color: rgba(255, 255, 255, 0.48);
+ font-size: 13px;
font-weight: 700;
+ text-align: center;
+}
+
+/* Run history cards — modeled on .automation-card / .history-entry styling.
+ Slim horizontal row, click to expand, expanded panel mirrors the
+ automation-history-modal grid + log layout. */
+.auto-sync-history-entry {
+ background: rgba(22, 22, 22, 0.95);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 10px;
+ transition: border-color 0.2s ease, background 0.2s ease;
}
.auto-sync-history-entry:hover {
- border-color: rgba(var(--accent-rgb), 0.2);
+ border-color: rgba(var(--accent-rgb), 0.22);
background: rgba(28, 28, 28, 0.98);
}
+.auto-sync-history-entry.expanded {
+ border-color: rgba(var(--accent-rgb), 0.32);
+ background: rgba(26, 26, 26, 0.98);
+}
+
+.auto-sync-history-entry-error { border-color: rgba(239, 68, 68, 0.22); }
+.auto-sync-history-entry-skipped { border-color: rgba(250, 204, 21, 0.18); }
+
.auto-sync-history-row {
display: flex;
- flex-direction: column;
- gap: 12px;
- padding: 18px 20px;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
cursor: pointer;
+ user-select: none;
}
.auto-sync-history-row:focus-visible {
outline: 2px solid rgba(var(--accent-rgb), 0.45);
- outline-offset: -4px;
- border-radius: 12px;
+ outline-offset: -2px;
+ border-radius: 10px;
}
-.auto-sync-history-status {
- padding: 4px 8px;
- border-radius: 999px;
- background: rgba(148, 163, 184, 0.14);
- color: #94a3b8;
- font-size: 10px;
- font-weight: 800;
- text-transform: uppercase;
-}
-
-.auto-sync-history-status.completed,
-.auto-sync-history-status.finished {
- background: rgba(74, 222, 128, 0.14);
- color: #4ade80;
-}
-
-.auto-sync-history-status.error {
- background: rgba(239, 68, 68, 0.14);
- color: #ef4444;
-}
-
-.auto-sync-history-status.skipped {
- background: rgba(250, 204, 21, 0.14);
- color: #facc15;
-}
-
-.auto-sync-history-card-head {
+.auto-sync-history-info {
+ flex: 1;
+ min-width: 0;
display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- min-width: 0;
+ flex-direction: column;
+ gap: 4px;
}
-.auto-sync-history-title-block {
- min-width: 0;
-}
-
-.auto-sync-history-title-row {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
- margin-bottom: 8px;
-}
-
-.auto-sync-history-title-row strong {
- min-width: 0;
- color: rgba(255, 255, 255, 0.88);
- font-size: 15px;
- font-weight: 700;
- line-height: 1.25;
+.auto-sync-history-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+}
+
+.auto-sync-history-flow {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.auto-sync-history-flow .flow-trigger,
+.auto-sync-history-flow .flow-action,
+.auto-sync-history-flow .flow-notify {
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 10px;
white-space: nowrap;
}
-.auto-sync-history-title-block small {
- display: block;
- margin-top: 8px;
- color: rgba(255, 255, 255, 0.4);
- font-size: 12px;
- line-height: 1.45;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: normal;
-}
+.auto-sync-history-flow .flow-trigger { background: rgba(var(--accent-rgb), 0.12); color: rgb(var(--accent-light-rgb)); }
+.auto-sync-history-flow .flow-action { background: rgba(88, 101, 242, 0.12); color: #7289da; }
+.auto-sync-history-flow .flow-notify { background: rgba(250, 204, 21, 0.10); color: #fbbf24; }
+.auto-sync-history-flow .flow-arrow { color: rgba(255, 255, 255, 0.25); font-size: 12px; }
-.auto-sync-history-preview {
+.auto-sync-history-meta-inline {
display: flex;
+ align-items: center;
flex-wrap: wrap;
gap: 8px;
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.4);
}
-.auto-sync-history-preview span {
- padding: 6px 9px;
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.045);
- color: rgba(255, 255, 255, 0.52);
- font-size: 11px;
- font-weight: 700;
+.auto-sync-history-status {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ padding: 2px 8px;
+ border-radius: 10px;
+ background: rgba(148, 163, 184, 0.14);
+ color: #94a3b8;
}
-.auto-sync-history-meta {
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-end;
- align-content: center;
- align-items: center;
- gap: 6px;
- min-width: 190px;
- max-width: 320px;
-}
+.auto-sync-history-status.completed,
+.auto-sync-history-status.finished { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
+.auto-sync-history-status.error { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
+.auto-sync-history-status.skipped { background: rgba(250, 204, 21, 0.15); color: #facc15; }
-.auto-sync-history-meta span,
-.auto-sync-history-result span,
-.auto-sync-history-meta button {
- padding: 4px 7px;
+.auto-sync-history-duration {
+ background: rgba(255, 255, 255, 0.05);
+ padding: 2px 6px;
border-radius: 6px;
- background: rgba(255, 255, 255, 0.055);
- border: 0;
- color: rgba(255, 255, 255, 0.48);
+ color: rgba(255, 255, 255, 0.55);
+}
+
+.auto-sync-history-delta {
font-size: 10px;
font-weight: 700;
- font-family: inherit;
+ padding: 2px 8px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.6);
+ margin-left: auto;
}
-.auto-sync-history-meta .auto-sync-history-expand-label {
- border: 1px solid rgba(var(--accent-rgb), 0.18);
- color: rgb(var(--accent-light-rgb));
+.auto-sync-history-delta.pos { background: rgba(74, 222, 128, 0.14); color: #4ade80; }
+.auto-sync-history-delta.neg { background: rgba(239, 68, 68, 0.14); color: #ef4444; }
+
+.auto-sync-history-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.auto-sync-history-expand-btn {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ width: 26px;
+ height: 26px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
cursor: pointer;
- padding-right: 20px;
- position: relative;
+ color: rgba(255, 255, 255, 0.45);
+ transition: all 0.2s ease;
}
-.auto-sync-history-meta .auto-sync-history-expand-label::after {
- content: '';
- position: absolute;
- right: 8px;
- top: 50%;
- width: 6px;
- height: 6px;
- border-right: 1.5px solid currentColor;
- border-bottom: 1.5px solid currentColor;
- transform: translateY(-65%) rotate(45deg);
- transition: transform 160ms ease;
+.auto-sync-history-expand-btn:hover {
+ background: rgba(var(--accent-rgb), 0.2);
+ border-color: rgba(var(--accent-rgb), 0.4);
+ color: rgb(var(--accent-light-rgb));
}
-.auto-sync-history-entry.expanded .auto-sync-history-expand-label::after {
- transform: translateY(-30%) rotate(225deg);
+.auto-sync-history-expand-icon {
+ font-size: 9px;
+ transition: transform 0.2s ease;
+ display: inline-block;
}
-.auto-sync-history-entry:hover .auto-sync-history-expand-label {
- background: rgba(var(--accent-rgb), 0.14);
- border-color: rgba(var(--accent-rgb), 0.35);
+.auto-sync-history-entry.expanded .auto-sync-history-expand-icon {
+ transform: rotate(180deg);
}
+/* Expanded detail — mirrors .history-stats-grid / .history-log-section
+ from the Automations page run-history modal. */
.auto-sync-history-detail {
- display: none;
- padding: 18px 20px 20px;
- border-top: 1px solid rgba(255, 255, 255, 0.07);
+ max-height: 0;
+ overflow: hidden;
+ transition: max-height 0.3s ease;
+ border-top: 1px solid transparent;
}
.auto-sync-history-detail.expanded {
+ max-height: 800px;
+ border-top-color: rgba(255, 255, 255, 0.06);
+ padding: 12px 14px 14px;
+}
+
+.auto-sync-history-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 8px;
+}
+
+.auto-sync-history-stat {
+ background: rgba(0, 0, 0, 0.25);
+ border-radius: 8px;
+ padding: 8px 12px;
+}
+
+.auto-sync-history-stat-label {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ color: rgba(255, 255, 255, 0.45);
+ margin-bottom: 4px;
+}
+
+.auto-sync-history-stat-value {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #e0e0e0;
+}
+
+.auto-sync-history-stat-value .stat-arrow {
+ color: rgba(255, 255, 255, 0.3);
+ font-size: 11px;
+}
+
+.auto-sync-history-stat-value .stat-delta {
+ font-size: 10px;
+ font-weight: 700;
+ padding: 1px 6px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.06);
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.auto-sync-history-stat-value .stat-delta.pos { background: rgba(74, 222, 128, 0.14); color: #4ade80; }
+.auto-sync-history-stat-value .stat-delta.neg { background: rgba(239, 68, 68, 0.14); color: #ef4444; }
+
+.auto-sync-history-facts-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 6px;
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.auto-sync-history-fact {
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: 2px;
}
-.auto-sync-history-detail-grid {
- display: grid;
- grid-template-columns: minmax(0, 1.45fr) minmax(220px, 0.8fr);
- gap: 12px;
-}
-
-.auto-sync-history-section {
- min-width: 0;
- padding: 12px;
- border: 1px solid rgba(255, 255, 255, 0.075);
- border-radius: 9px;
- background: rgba(255, 255, 255, 0.028);
-}
-
-.auto-sync-history-section-title {
- color: rgba(255, 255, 255, 0.7);
- font-size: 10px;
- font-weight: 850;
- letter-spacing: 0;
+.auto-sync-history-fact span {
+ font-size: 9px;
+ font-weight: 700;
text-transform: uppercase;
+ letter-spacing: 0.3px;
+ color: rgba(255, 255, 255, 0.35);
}
-.auto-sync-history-stats {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 8px;
- padding-top: 10px;
+.auto-sync-history-fact strong {
+ font-size: 11px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.78);
+ overflow-wrap: anywhere;
}
-.auto-sync-history-stats div {
- padding: 10px;
- border-radius: 7px;
- background: rgba(255, 255, 255, 0.04);
-}
-
-.auto-sync-history-stats span,
-.auto-sync-history-stats strong {
- display: block;
-}
-
-.auto-sync-history-stats span {
- color: rgba(255, 255, 255, 0.38);
- font-size: 10px;
- font-weight: 800;
- text-transform: uppercase;
-}
-
-.auto-sync-history-stats strong {
- margin-top: 4px;
- color: rgba(255, 255, 255, 0.82);
- font-size: 12px;
-}
-
-.auto-sync-history-result {
+.auto-sync-history-result-row {
display: flex;
flex-wrap: wrap;
- gap: 7px;
- margin-top: 10px;
-}
-
-.auto-sync-history-result span {
- color: rgb(var(--accent-light-rgb));
- background: rgba(var(--accent-rgb), 0.1);
- border: 1px solid rgba(var(--accent-rgb), 0.18);
-}
-
-.auto-sync-history-result span.error {
- color: #ef4444;
- background: rgba(239, 68, 68, 0.1);
- border-color: rgba(239, 68, 68, 0.18);
-}
-
-.auto-sync-history-result span.muted {
- color: rgba(255, 255, 255, 0.42);
- background: rgba(255, 255, 255, 0.045);
- border-color: rgba(255, 255, 255, 0.08);
-}
-
-.auto-sync-history-snapshots {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
-}
-
-.auto-sync-history-facts,
-.auto-sync-history-payload {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- margin-top: 10px;
-}
-
-.auto-sync-history-facts.compact {
- grid-template-columns: repeat(3, minmax(0, 1fr));
-}
-
-.auto-sync-history-facts div,
-.auto-sync-history-payload div {
- min-width: 0;
- padding: 9px;
- border-radius: 7px;
- background: rgba(255, 255, 255, 0.04);
-}
-
-.auto-sync-history-facts span,
-.auto-sync-history-payload span,
-.auto-sync-history-facts strong,
-.auto-sync-history-payload strong {
- display: block;
- min-width: 0;
-}
-
-.auto-sync-history-facts span,
-.auto-sync-history-payload span {
- color: rgba(255, 255, 255, 0.36);
- font-size: 10px;
- font-weight: 800;
- text-transform: uppercase;
-}
-
-.auto-sync-history-facts strong,
-.auto-sync-history-payload strong {
- margin-top: 4px;
- color: rgba(255, 255, 255, 0.78);
- font-size: 11px;
- line-height: 1.35;
- overflow-wrap: anywhere;
-}
-
-.auto-sync-history-logs {
- display: flex;
- flex-direction: column;
gap: 6px;
- max-height: 190px;
- overflow: auto;
margin-top: 10px;
- padding: 10px;
- border-radius: 8px;
- background: rgba(0, 0, 0, 0.18);
}
-.auto-sync-history-logs div {
- color: rgba(255, 255, 255, 0.54);
- font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
+.auto-sync-history-result-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 8px;
+ border-radius: 10px;
+ background: rgba(var(--accent-rgb), 0.10);
+ border: 1px solid rgba(var(--accent-rgb), 0.18);
+ color: rgb(var(--accent-light-rgb));
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.auto-sync-history-result-pill em {
+ font-style: normal;
+ color: rgba(255, 255, 255, 0.5);
+ font-weight: 700;
font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.auto-sync-history-error {
+ margin-top: 10px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: rgba(239, 68, 68, 0.10);
+ border: 1px solid rgba(239, 68, 68, 0.22);
+ color: #f87171;
+ font-size: 11px;
line-height: 1.45;
+}
+
+.auto-sync-history-log-section {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
+ max-height: 240px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
+}
+
+.auto-sync-history-log-line {
+ padding: 3px 0;
+ font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', ui-monospace, monospace;
+ font-size: 10px;
+ line-height: 1.5;
overflow-wrap: anywhere;
}
-.auto-sync-history-logs .success { color: #4ade80; }
-.auto-sync-history-logs .error { color: #ef4444; }
-.auto-sync-history-logs .warning { color: #facc15; }
+.auto-sync-history-log-info { color: rgba(255, 255, 255, 0.5); }
+.auto-sync-history-log-success { color: #4ade80; }
+.auto-sync-history-log-error { color: #ef4444; }
+.auto-sync-history-log-warning,
+.auto-sync-history-log-warn { color: #facc15; }
+.auto-sync-history-log-skip { color: rgba(255, 255, 255, 0.35); }
.auto-sync-history-empty {
margin: 24px;