-
${_esc(playlist.name)}
+
+ ${health.level !== 'ok' ? `${health.level === 'failing' ? '!' : '⚠'}` : ''}
+ ${_esc(playlist.name)}
+
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
${_esc(autoSyncIntervalLabel(schedule?.hours || 24))}
@@ -962,6 +1065,25 @@ function autoSyncScheduledCardHtml(playlist, schedule) {
`;
}
+function autoSyncPlaylistHealth(playlistId) {
+ // Look at the last 3 runs for this playlist in the loaded history.
+ // 3-in-a-row errors = failing (red dot). 1+ recent error = warning.
+ const history = _autoSyncScheduleState.runHistory || [];
+ const id = parseInt(playlistId, 10);
+ const recent = history
+ .filter(h => parseInt(h.playlist_id, 10) === id)
+ .slice(0, 3);
+ if (!recent.length) return { level: 'ok', tooltip: '' };
+ const errored = recent.filter(h => h.status === 'error' || h.status === 'skipped');
+ if (errored.length >= 3) {
+ return { level: 'failing', tooltip: `Last ${recent.length} runs failed — check Run History tab` };
+ }
+ if (errored.length) {
+ return { level: 'warning', tooltip: `${errored.length} of last ${recent.length} runs failed` };
+ }
+ return { level: 'ok', tooltip: '' };
+}
+
function autoSyncNextRunLabel(nextRun) {
if (!nextRun) return '';
const ts = _autoParseUTC(nextRun);
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 8a8c621f..5529e794 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.6.2': [
{ date: 'May 24, 2026 — 2.6.2 release' },
+ { title: 'Auto-Sync manager: filter, failure indicators, history filters + load more', desc: 'a handful of UX additions on the Playlist Auto-Sync modal. sidebar gets a "Filter playlists…" search input so you can narrow down a long mirrored-playlist list. scheduled cards on the board now show a red ! badge when the last three pipeline runs all failed (yellow ⚠ if at least one of the last few failed) so chronically broken schedules surface visually instead of getting buried in the run-history tab. run history tab title shows a red error count when there are failed runs in the loaded window. the history tab itself gains All / Errors / Completed filter pills, a "Load more" footer that pulls another 50 entries, and a "Run pipeline again" button inside the expanded detail panel so you can re-trigger a specific playlist without leaving the modal. also dropped the "Discovered: completed" pill — `tracks_discovered` was a status string, not a count, and the same data is already in the before/after stats grid above.' },
{ 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.' },
diff --git a/webui/static/style.css b/webui/static/style.css
index afc4ed5d..9d4167f1 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -11622,6 +11622,56 @@ body.helper-mode-active #dashboard-activity-feed:hover {
letter-spacing: 0.3px;
}
+.auto-sync-sidebar-filter {
+ position: relative;
+ padding: 4px 16px 10px;
+}
+
+.auto-sync-sidebar-search {
+ width: 100%;
+ height: 30px;
+ padding: 0 28px 0 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.3);
+ color: #fff;
+ font-size: 12px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.2s ease, background 0.2s ease;
+}
+
+.auto-sync-sidebar-search:focus {
+ border-color: rgba(var(--accent-rgb), 0.45);
+ background: rgba(var(--accent-rgb), 0.04);
+}
+
+.auto-sync-sidebar-search::placeholder {
+ color: rgba(255, 255, 255, 0.3);
+}
+
+.auto-sync-sidebar-filter-clear {
+ position: absolute;
+ right: 22px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 18px;
+ height: 18px;
+ border: 0;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.65);
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ padding: 0;
+}
+
+.auto-sync-sidebar-filter-clear:hover {
+ background: rgba(239, 68, 68, 0.2);
+ color: #ef4444;
+}
+
.auto-sync-source-list {
min-height: 0;
overflow-y: auto;
@@ -11963,6 +12013,189 @@ body.helper-mode-active #dashboard-activity-feed:hover {
border-color: rgba(239, 68, 68, 0.4);
}
+.auto-sync-scheduled-card.failing {
+ border-color: rgba(239, 68, 68, 0.4);
+ box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.15);
+}
+
+.auto-sync-scheduled-card.warning {
+ border-color: rgba(250, 204, 21, 0.3);
+}
+
+.auto-sync-scheduled-health {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ margin-right: 6px;
+ font-size: 10px;
+ font-weight: 800;
+ line-height: 1;
+ vertical-align: middle;
+}
+
+.auto-sync-scheduled-health.failing {
+ background: rgba(239, 68, 68, 0.18);
+ color: #ef4444;
+ box-shadow: 0 0 6px rgba(239, 68, 68, 0.35);
+}
+
+.auto-sync-scheduled-health.warning {
+ background: rgba(250, 204, 21, 0.18);
+ color: #facc15;
+}
+
+/* Run history filters + load more + tab badge */
+.auto-sync-history-intro-controls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.auto-sync-history-filters {
+ display: flex;
+ gap: 4px;
+ padding: 3px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ background: rgba(0, 0, 0, 0.25);
+}
+
+.auto-sync-history-filter-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 26px;
+ padding: 0 10px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.5);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 600;
+ font-family: inherit;
+ transition: color 0.15s ease, background 0.15s ease;
+}
+
+.auto-sync-history-filter-btn:hover {
+ color: rgba(255, 255, 255, 0.85);
+}
+
+.auto-sync-history-filter-btn.active {
+ background: rgba(var(--accent-rgb), 0.18);
+ color: rgb(var(--accent-light-rgb));
+}
+
+.auto-sync-history-filter-btn em {
+ font-style: normal;
+ padding: 1px 6px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.06);
+ color: rgba(255, 255, 255, 0.55);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.auto-sync-history-filter-btn.active em {
+ background: rgba(var(--accent-rgb), 0.25);
+ color: rgb(var(--accent-light-rgb));
+}
+
+.auto-sync-history-filter-btn.has-errors:not(.active) em {
+ background: rgba(239, 68, 68, 0.18);
+ color: #ef4444;
+}
+
+.auto-sync-history-filter-btn.has-errors.active {
+ background: rgba(239, 68, 68, 0.16);
+ color: #ef4444;
+}
+
+.auto-sync-history-load-more-row {
+ display: flex;
+ justify-content: center;
+ padding: 12px 14px 20px;
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.auto-sync-history-load-more {
+ height: 32px;
+ padding: 0 18px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.7);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.2px;
+ transition: all 0.2s ease;
+ font-family: inherit;
+}
+
+.auto-sync-history-load-more:hover {
+ background: rgba(var(--accent-rgb), 0.16);
+ border-color: rgba(var(--accent-rgb), 0.4);
+ color: rgb(var(--accent-light-rgb));
+}
+
+/* Tab badge (error count on Run History tab) */
+.auto-sync-tab-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 16px;
+ padding: 0 5px;
+ margin-left: 6px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ vertical-align: middle;
+}
+
+.auto-sync-tab-badge.error {
+ background: rgba(239, 68, 68, 0.2);
+ color: #ef4444;
+}
+
+/* Run-again button inside history expanded detail */
+.auto-sync-history-detail-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.auto-sync-history-run-again {
+ height: 30px;
+ padding: 0 14px;
+ border: 1px solid rgba(var(--accent-rgb), 0.3);
+ border-radius: 8px;
+ background: rgba(var(--accent-rgb), 0.12);
+ color: rgb(var(--accent-light-rgb));
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.3px;
+ text-transform: uppercase;
+ font-family: inherit;
+ transition: all 0.2s ease;
+}
+
+.auto-sync-history-run-again:hover {
+ background: rgba(var(--accent-rgb), 0.22);
+ border-color: rgba(var(--accent-rgb), 0.5);
+ color: rgb(var(--accent-neon-rgb));
+}
+
.auto-sync-automation-list {
min-height: 0;
overflow-y: auto;