Auto-Sync: bulk schedule per source + custom interval columns
Two upgrades to the schedule board: Bulk schedule. Each source group in the sidebar gets a small "Bulk" button next to the title. Clicking it opens a popover with the same ten standard buckets plus "Custom interval…" (prompts for hours) and "Unschedule all". Picking a bucket POSTs/PUTs the schedule for every schedulable playlist in that source. Result toast aggregates ok/fail counts. Big quality-of-life for "I want every Spotify playlist weekly" without 30 individual drags. Custom interval columns. The board's column set is no longer the hardcoded `AUTO_SYNC_BUCKETS` list — it's the union of those plus any hour values currently in use by playlist_schedules. A 6h or 36h schedule (created via the bulk custom prompt, or hand-edited in the Automations page) now renders as its own dashed-border column instead of silently disappearing from the board because it didn't match a standard bucket. Standard columns still render solid; custom ones get a "custom" eyebrow + dashed border so they're visually distinct.
This commit is contained in:
parent
d668bf4e6d
commit
d18f45dfec
3 changed files with 269 additions and 5 deletions
|
|
@ -279,7 +279,14 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
|
|||
|
||||
const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => `
|
||||
<div class="auto-sync-source-group">
|
||||
<div class="auto-sync-source-title">${_esc(autoSyncSourceLabel(source))}</div>
|
||||
<div class="auto-sync-source-group-head">
|
||||
<span class="auto-sync-source-title">${_esc(autoSyncSourceLabel(source))}</span>
|
||||
<button type="button" class="auto-sync-source-bulk-btn"
|
||||
onclick="event.stopPropagation(); openAutoSyncBulkMenu(event, '${_escAttr(source)}')"
|
||||
title="Schedule all ${_escAttr(autoSyncSourceLabel(source))} playlists at the same interval">
|
||||
Bulk
|
||||
</button>
|
||||
</div>
|
||||
${grouped[source].map(p => {
|
||||
const schedule = playlistSchedules[p.id];
|
||||
const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled';
|
||||
|
|
@ -305,12 +312,21 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
|
|||
</div>
|
||||
` : '';
|
||||
|
||||
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 `
|
||||
<div class="auto-sync-column" data-hours="${hours}" ondragover="autoSyncDragOver(event)" ondragleave="autoSyncDragLeave(event)" ondrop="autoSyncDrop(event, ${hours})">
|
||||
<div class="auto-sync-column ${isCustom ? 'custom' : ''}" data-hours="${hours}" ondragover="autoSyncDragOver(event)" ondragleave="autoSyncDragLeave(event)" ondrop="autoSyncDrop(event, ${hours})">
|
||||
<div class="auto-sync-column-head">
|
||||
<span>${autoSyncBucketLabel(hours)}</span>
|
||||
<span>${autoSyncBucketLabel(hours)}${isCustom ? ' <em>custom</em>' : ''}</span>
|
||||
<small>${assigned.length} playlist${assigned.length === 1 ? '' : 's'}</small>
|
||||
</div>
|
||||
<div class="auto-sync-column-list">
|
||||
|
|
@ -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 => `
|
||||
<button type="button" onclick="bulkScheduleAutoSyncSource('${_escAttr(source)}', ${h})">
|
||||
${_esc(autoSyncIntervalLabel(h))}
|
||||
</button>
|
||||
`).join('');
|
||||
menu.innerHTML = `
|
||||
<div class="auto-sync-bulk-menu-title">Schedule all ${_esc(autoSyncSourceLabel(source))}</div>
|
||||
<div class="auto-sync-bulk-menu-buckets">${buttons}</div>
|
||||
<button type="button" class="auto-sync-bulk-menu-custom"
|
||||
onclick="promptAutoSyncBulkCustom('${_escAttr(source)}')">
|
||||
Custom interval…
|
||||
</button>
|
||||
<button type="button" class="auto-sync-bulk-menu-unschedule"
|
||||
onclick="bulkUnscheduleAutoSyncSource('${_escAttr(source)}')">
|
||||
Unschedule all
|
||||
</button>
|
||||
`;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue