- ${autoSyncBucketLabel(hours)}
+ ${autoSyncBucketLabel(hours)}${isCustom ? ' custom' : ''}
${assigned.length} playlist${assigned.length === 1 ? '' : 's'}
@@ -511,6 +527,145 @@ function loadMoreAutoSyncHistory() {
refreshAutoSyncScheduleModal();
}
+function openAutoSyncBulkMenu(event, source) {
+ // Build a transient popover with all the standard buckets + a "Custom…"
+ // entry. Position relative to the button that triggered it.
+ closeAutoSyncBulkMenu();
+ const anchor = event.currentTarget;
+ if (!anchor) return;
+ const menu = document.createElement('div');
+ menu.className = 'auto-sync-bulk-menu';
+ menu.id = 'auto-sync-bulk-menu';
+ const buckets = [...AUTO_SYNC_BUCKETS];
+ const buttons = buckets.map(h => `
+
+ `).join('');
+ menu.innerHTML = `
+
+
+
+
+ `;
+ document.body.appendChild(menu);
+ const rect = anchor.getBoundingClientRect();
+ menu.style.top = `${rect.bottom + 4}px`;
+ menu.style.left = `${Math.max(8, rect.right - menu.offsetWidth)}px`;
+ // Close on outside click
+ setTimeout(() => {
+ document.addEventListener('click', _autoSyncBulkMenuOutsideClick, { once: true });
+ }, 0);
+}
+
+function _autoSyncBulkMenuOutsideClick(event) {
+ const menu = document.getElementById('auto-sync-bulk-menu');
+ if (menu && !menu.contains(event.target)) closeAutoSyncBulkMenu();
+}
+
+function closeAutoSyncBulkMenu() {
+ const existing = document.getElementById('auto-sync-bulk-menu');
+ if (existing) existing.remove();
+}
+
+function promptAutoSyncBulkCustom(source) {
+ closeAutoSyncBulkMenu();
+ const raw = window.prompt('Custom interval in hours (e.g. 6, 36, 96):', '6');
+ if (raw === null) return;
+ const hours = parseInt(raw, 10);
+ if (!Number.isFinite(hours) || hours < 1) {
+ showToast('Interval must be a whole number of hours, 1 or greater', 'error');
+ return;
+ }
+ bulkScheduleAutoSyncSource(source, hours);
+}
+
+async function bulkScheduleAutoSyncSource(source, hours) {
+ closeAutoSyncBulkMenu();
+ const { playlists } = _autoSyncScheduleState;
+ const targets = (playlists || []).filter(p => p.source === source && autoSyncCanSchedulePlaylist(p));
+ if (!targets.length) {
+ showToast(`No schedulable ${autoSyncSourceLabel(source)} playlists`, 'info');
+ return;
+ }
+ if (!await showConfirmDialog({
+ title: `Schedule ${targets.length} ${autoSyncSourceLabel(source)} playlist${targets.length === 1 ? '' : 's'}`,
+ message: `Every ${autoSyncIntervalLabel(hours).toLowerCase().replace(/^every /, '')}. Existing schedules in this source will be updated.`,
+ })) return;
+ let ok = 0, fail = 0;
+ for (const playlist of targets) {
+ try {
+ await saveAutoSyncPlaylistScheduleSilent(playlist.id, hours);
+ ok++;
+ } catch (_err) {
+ fail++;
+ }
+ }
+ showToast(`Scheduled ${ok} ${autoSyncSourceLabel(source)} playlist${ok === 1 ? '' : 's'} at ${autoSyncBucketLabel(hours)}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success');
+ await refreshAutoSyncScheduleModal();
+}
+
+async function bulkUnscheduleAutoSyncSource(source) {
+ closeAutoSyncBulkMenu();
+ const { playlists, playlistSchedules } = _autoSyncScheduleState;
+ const targets = (playlists || []).filter(p => p.source === source && playlistSchedules[p.id]);
+ if (!targets.length) {
+ showToast(`No scheduled ${autoSyncSourceLabel(source)} playlists to unschedule`, 'info');
+ return;
+ }
+ if (!await showConfirmDialog({
+ title: `Unschedule ${targets.length} ${autoSyncSourceLabel(source)} playlist${targets.length === 1 ? '' : 's'}`,
+ message: 'Removes the Auto-Sync schedules. Mirrored playlists themselves stay.',
+ })) return;
+ let ok = 0, fail = 0;
+ for (const playlist of targets) {
+ const schedule = playlistSchedules[playlist.id];
+ if (!schedule) continue;
+ try {
+ const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ ok++;
+ } catch (_err) {
+ fail++;
+ }
+ }
+ showToast(`Removed ${ok} schedule${ok === 1 ? '' : 's'}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success');
+ await refreshAutoSyncScheduleModal();
+}
+
+async function saveAutoSyncPlaylistScheduleSilent(playlistId, hours) {
+ // Like saveAutoSyncPlaylistSchedule but without toasts/refresh — caller
+ // batches feedback. Re-uses the existing automation row when one already
+ // exists for the playlist.
+ const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
+ if (!playlist) throw new Error('playlist not found');
+ const existing = _autoSyncScheduleState.playlistSchedules[playlistId];
+ const payload = {
+ name: `Auto-Sync: ${playlist.name}`,
+ trigger_type: 'schedule',
+ trigger_config: autoSyncTriggerForHours(hours),
+ action_type: 'playlist_pipeline',
+ action_config: { playlist_id: String(playlistId), all: false },
+ then_actions: [],
+ group_name: 'Playlist Auto-Sync',
+ owned_by: 'auto_sync',
+ };
+ const res = await fetch(existing ? `/api/automations/${existing.automation_id}` : '/api/automations', {
+ method: existing ? 'PUT' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ const data = await res.json();
+ if (!res.ok || data.error) throw new Error(data.error || 'Failed');
+ return data;
+}
+
function populateAutoSyncHistoryList(root = document) {
const list = root.querySelector('.auto-sync-history-list');
if (!list) return;
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 5529e794..576046ca 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: bulk schedule by source + custom interval columns', desc: 'each source group in the sidebar gets a Bulk button — opens a popover that lets you schedule every playlist in that group at a chosen interval in one click (1h / 2h / 4h / 8h / 12h / 16h / 24h / 48h / 72h / weekly) or "Custom interval…" for an arbitrary number of hours. also includes "Unschedule all" to clear a source\'s schedules. on the board itself, a schedule with a non-standard interval (e.g. 6h or 36h, created via Automations page or the custom prompt) now renders as its own dashed-border column instead of disappearing because it didn\'t match a hardcoded bucket.' },
{ 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.' },
diff --git a/webui/static/style.css b/webui/static/style.css
index 9d4167f1..e4c31311 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -11682,8 +11682,15 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin-bottom: 14px;
}
-.auto-sync-source-title {
+.auto-sync-source-group-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
padding: 6px 4px 4px;
+}
+
+.auto-sync-source-title {
color: rgba(255, 255, 255, 0.38);
font-size: 10px;
font-weight: 700;
@@ -11691,6 +11698,107 @@ body.helper-mode-active #dashboard-activity-feed:hover {
letter-spacing: 0.3px;
}
+.auto-sync-source-bulk-btn {
+ height: 20px;
+ padding: 0 8px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ cursor: pointer;
+ font-family: inherit;
+ transition: all 0.2s ease;
+}
+
+.auto-sync-source-bulk-btn:hover {
+ background: rgba(var(--accent-rgb), 0.16);
+ border-color: rgba(var(--accent-rgb), 0.35);
+ color: rgb(var(--accent-light-rgb));
+}
+
+/* Bulk-schedule popover for a source group */
+.auto-sync-bulk-menu {
+ position: fixed;
+ z-index: 10001;
+ min-width: 220px;
+ padding: 8px;
+ background: linear-gradient(135deg, #1f1f1f 0%, #161616 100%);
+ border: 1px solid rgba(var(--accent-rgb), 0.25);
+ border-radius: 10px;
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(var(--accent-rgb), 0.08);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.auto-sync-bulk-menu-title {
+ padding: 6px 8px 4px;
+ color: rgba(255, 255, 255, 0.55);
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.auto-sync-bulk-menu-buckets {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 4px;
+}
+
+.auto-sync-bulk-menu button {
+ padding: 6px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.03);
+ color: rgba(255, 255, 255, 0.75);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 600;
+ text-align: left;
+ font-family: inherit;
+ transition: all 0.15s ease;
+}
+
+.auto-sync-bulk-menu button:hover {
+ background: rgba(var(--accent-rgb), 0.16);
+ border-color: rgba(var(--accent-rgb), 0.35);
+ color: rgb(var(--accent-light-rgb));
+}
+
+.auto-sync-bulk-menu-custom {
+ margin-top: 2px;
+}
+
+.auto-sync-bulk-menu-unschedule {
+ color: rgba(255, 255, 255, 0.55) !important;
+}
+
+.auto-sync-bulk-menu-unschedule:hover {
+ background: rgba(239, 68, 68, 0.18) !important;
+ border-color: rgba(239, 68, 68, 0.4) !important;
+ color: #ef4444 !important;
+}
+
+/* Custom-interval column tag */
+.auto-sync-column.custom {
+ border-style: dashed;
+}
+
+.auto-sync-column-head em {
+ font-style: normal;
+ color: rgba(var(--accent-rgb), 0.7);
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ margin-left: 4px;
+}
+
.auto-sync-playlist,
.auto-sync-scheduled-card {
border: 1px solid rgba(255, 255, 255, 0.07);