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;