diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index ce6dc3c4..48d897cd 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -279,7 +279,14 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => `
-
${_esc(autoSyncSourceLabel(source))}
+
+ ${_esc(autoSyncSourceLabel(source))} + +
${grouped[source].map(p => { const schedule = playlistSchedules[p.id]; const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; @@ -305,12 +312,21 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
` : ''; - const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => { + // Merge standard buckets with any custom intervals that are already in + // use, so a 6h or 36h schedule (created via Automations page or the + // custom-interval prompt) still renders as its own column instead of + // disappearing from the board. + const customHours = Object.values(playlistSchedules) + .map(s => parseInt(s?.hours, 10)) + .filter(h => Number.isFinite(h) && h > 0 && !AUTO_SYNC_BUCKETS.includes(h)); + const allBuckets = [...new Set([...AUTO_SYNC_BUCKETS, ...customHours])].sort((a, b) => a - b); + const bucketHtml = allBuckets.map(hours => { const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); + const isCustom = !AUTO_SYNC_BUCKETS.includes(hours); return ` -
+
- ${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 = ` +
Schedule all ${_esc(autoSyncSourceLabel(source))}
+
${buttons}
+ + + `; + 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);