// Auto-Sync: schedule board + mirrored-playlist pipeline runs // ───────────────────────────────────────────────────────────────────── // Extracted from stats-automations.js (Cin review feedback). All // references rely on globals available at runtime — `_esc`, `_escAttr`, // `_autoParseUTC`, `_autoFormatTrigger`, `showToast`, `showConfirmDialog`, // `loadMirroredPlaylists`, `updateMirroredCardPhase`, // `openMirroredPlaylistModal`, `closeMirroredModal`, `youtubePlaylistStates` // all live in stats-automations.js (or earlier helpers). This file // declares the auto-sync-specific state + render/event functions on top. const mirroredPipelinePollers = {}; const AUTO_SYNC_BUCKETS = [1, 2, 4, 8, 12, 16, 24, 48, 72, 168]; let _autoSyncStatusPoller = null; let _autoSyncIsDragging = false; let _autoSyncScheduleState = { playlists: [], automations: [], playlistSchedules: {}, weeklySchedules: {}, automationPipelines: [], runHistory: [], runHistoryTotal: 0, }; let _autoSyncActiveTab = 'schedule'; let _autoSyncSidebarFilter = ''; let _autoSyncHistoryFilter = 'all'; // 'all' | 'error' | 'completed' | 'skipped' let _autoSyncHistoryLimit = 50; // Open weekly-editor popover state. ``null`` when no popover is open. // Tracks playlist id + the current draft (time / days / tz) so the // editor is a controlled component — clicking outside without saving // discards the draft. let _autoSyncWeeklyEditor = null; function getMirroredSourceRef(p) { if (p && p.source_ref) return String(p.source_ref); const desc = (p && p.description) ? String(p.description).trim() : ''; if ((p.source === 'spotify_public' || p.source === 'youtube') && /^https?:\/\//i.test(desc)) { return desc; } return (p && p.source_playlist_id) ? String(p.source_playlist_id) : ''; } function autoSyncTriggerForHours(hours) { const h = parseInt(hours, 10) || 24; if (h >= 24 && h % 24 === 0) { return { interval: h / 24, unit: 'days' }; } return { interval: h, unit: 'hours' }; } function autoSyncHoursFromTrigger(config) { const interval = parseInt(config?.interval, 10) || 0; const unit = config?.unit || 'hours'; if (!interval) return null; if (unit === 'minutes') return Math.max(1, Math.round(interval / 60)); if (unit === 'days') return interval * 24; if (unit === 'weeks') return interval * 168; return interval; } function autoSyncBucketLabel(hours) { if (hours === 168) return 'Weekly'; if (hours >= 24) return `${hours / 24}d`; return `${hours}h`; } function autoSyncIntervalLabel(hours) { if (hours === 168) return 'Every week'; if (hours >= 24) { const days = hours / 24; return `Every ${days} day${days === 1 ? '' : 's'}`; } return `Every ${hours} hour${hours === 1 ? '' : 's'}`; } // Short cadence word for the lane badge (under the interval number). function autoSyncLaneCadence(hours) { if (hours === 1) return 'Hourly'; if (hours === 12) return 'Twice a day'; if (hours === 24) return 'Daily'; if (hours === 168) return 'Weekly'; if (hours < 24) return `Every ${hours}h`; const days = hours / 24; return `Every ${days} days`; } // Browser-detected default tz for new schedules. Used when the user // creates a weekly schedule and hasn't picked an explicit tz — falls // back to UTC on browsers where Intl is unavailable (very old ones). function detectBrowserTimezone() { try { const tz = typeof Intl !== 'undefined' && Intl.DateTimeFormat && Intl.DateTimeFormat().resolvedOptions().timeZone; return tz || 'UTC'; } catch (_) { return 'UTC'; } } // Canonical weekday order Mon-Sun. Matches both the backend // ``next_run_at`` weekday_map and the column ordering in the UI. // Keeping the abbreviations short-lowercase ('mon' not 'MON' / 'Mon') // matches the engine's existing config payload convention. const AUTO_SYNC_WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const AUTO_SYNC_WEEKDAY_LABELS = { mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun', }; // Build a ``weekly_time`` trigger_config payload from picker input. // Defensive — caller may pass garbage; we clamp / drop / default so // the resulting payload always passes ``next_run_at`` validation. function autoSyncWeeklyTrigger({ time, days, tz } = {}) { const safeTime = typeof time === 'string' && /^\d{1,2}:\d{2}$/.test(time) ? time : '09:00'; const safeDays = Array.isArray(days) ? days.filter(d => AUTO_SYNC_WEEKDAYS.includes(d)) : []; const safeTz = (typeof tz === 'string' && tz) ? tz : detectBrowserTimezone(); return { time: safeTime, days: safeDays, tz: safeTz }; } // Parse the days/time/tz back out of a ``weekly_time`` trigger_config, // with defensive fallbacks so a hand-edited row doesn't crash render. // Returns null when the config isn't recognisable as a weekly trigger. function autoSyncWeeklyFromTrigger(config) { if (!config || typeof config !== 'object') return null; const rawTime = typeof config.time === 'string' && /^\d{1,2}:\d{2}$/.test(config.time) ? config.time : '09:00'; let days = Array.isArray(config.days) ? config.days.map(d => String(d).toLowerCase()).filter(d => AUTO_SYNC_WEEKDAYS.includes(d)) : []; // Empty / all-invalid days = "every day" per next_run_at convention. // Surface that so the UI can render the schedule under all 7 day // columns instead of treating it as unscheduled. if (days.length === 0) days = [...AUTO_SYNC_WEEKDAYS]; const tz = (typeof config.tz === 'string' && config.tz) ? config.tz : 'UTC'; return { time: rawTime, days, tz }; } // Human-readable label for a weekly schedule. Used on card metadata // and column tooltips. Multi-day schedules collapse to "Mon, Wed, Fri // @09:00"; full-week schedules collapse to "Daily @ 09:00". function autoSyncWeeklyLabel(parsed) { if (!parsed) return 'Unscheduled'; const { time, days } = parsed; if (!Array.isArray(days) || days.length === 0) return `Daily @ ${time}`; if (days.length === 7) return `Daily @ ${time}`; // Sort to canonical Mon-Sun order so card text doesn't shuffle // when the user toggles days on/off in arbitrary order. const ordered = AUTO_SYNC_WEEKDAYS.filter(d => days.includes(d)); const dayList = ordered.map(d => AUTO_SYNC_WEEKDAY_LABELS[d]).join(', '); return `${dayList} @ ${time}`; } function autoSyncSourceLabel(source) { const labels = { spotify: 'Spotify', spotify_public: 'Spotify Link', tidal: 'Tidal', youtube: 'YouTube', deezer: 'Deezer', qobuz: 'Qobuz', beatport: 'Beatport', file: 'File Imports', itunes_link: 'iTunes Link', listenbrainz: 'ListenBrainz', lastfm: 'Last.fm Radio', soulsync_discovery: 'SoulSync Discovery', }; return labels[source] || source || 'Other'; } // Per-source logo URLs for the sidebar source-group headers and // anywhere else a small branded chip helps disambiguate the source. // Same URLs the dashboard equalizer / header-action orbs reference // so the visual language stays consistent. Sources without a // readily available logo (``beatport``, ``file``) fall through to // no-image; the source-icon element drops to display:none via // the ```` swap so the header renders cleanly without // a broken-image placeholder. const _AUTO_SYNC_SOURCE_LOGOS = { spotify: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', spotify_public: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', tidal: 'https://www.svgrepo.com/show/519734/tidal.svg', youtube: 'https://www.svgrepo.com/show/13671/youtube.svg', deezer: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610', qobuz: 'https://www.svgrepo.com/show/504778/qobuz.svg', itunes_link: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png', lastfm: 'https://www.last.fm/static/images/lastfm_avatar_twitter.52a5d69a85ac.png', listenbrainz: 'https://listenbrainz.org/static/img/listenbrainz-logo-no-text.svg', soulsync_discovery: '/static/favicon.png', }; function autoSyncSourceIconHtml(source) { const src = _AUTO_SYNC_SOURCE_LOGOS[source]; if (!src) return ''; return ``; } function autoSyncCanSchedulePlaylist(playlist) { if (!playlist) return false; const src = playlist.source || ''; // ``file`` + ``beatport`` have no external refresh hook. // ``lastfm`` is excluded because each Last.fm Radio playlist is a // seed-track-specific similar-tracks snapshot that doesn't update // on the Last.fm side — auto-syncing it would just re-discover the // same 25 tracks every interval. Users mirror Last.fm radios once // to grab the downloads, then move on; they belong in the // Mirrored / Sync tab but not the Auto-Sync schedule board. return !['file', 'beatport', 'lastfm'].includes(src); } function autoSyncIsPipelineAutomation(auto) { return auto && auto.action_type === 'playlist_pipeline'; } function autoSyncPlaylistIdFromAutomation(auto) { if (!autoSyncIsPipelineAutomation(auto)) return null; const cfg = auto.action_config || {}; if (cfg.all === true || cfg.all === 'true') return null; const raw = cfg.playlist_id; if (raw === undefined || raw === null || raw === '') return null; const id = parseInt(raw, 10); return Number.isFinite(id) ? id : null; } function autoSyncIsScheduleOwned(auto) { // Primary signal: the explicit owned_by flag the board writes on every // schedule it creates. Falls back to the legacy name/group convention // so rows created before the column existed (or hand-edited from the // Automations page) still get recognized after backfill. if (auto?.owned_by === 'auto_sync') return true; const group = auto?.group_name || ''; const name = auto?.name || ''; return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:'); } function buildAutoSyncScheduleState(playlists, automations, historyData = {}) { const playlistSchedules = {}; const weeklySchedules = {}; const automationPipelines = []; const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation); pipelineAutomations.forEach(auto => { const playlistId = autoSyncPlaylistIdFromAutomation(auto); const isOwned = autoSyncIsScheduleOwned(auto); if (playlistId && isOwned && auto.trigger_type === 'schedule') { const hours = autoSyncHoursFromTrigger(auto.trigger_config || {}); if (hours) { playlistSchedules[playlistId] = { automation_id: auto.id, automation_name: auto.name, hours, enabled: auto.enabled !== false && auto.enabled !== 0, owned: true, next_run: auto.next_run, trigger_config: auto.trigger_config || {}, }; return; } } if (playlistId && isOwned && auto.trigger_type === 'weekly_time') { // No ``|| {}`` coercion here on purpose — null / non-object // trigger_config from a hand-edited row should fall through // to automationPipelines as a "broken row" rather than be // silently bucketed as an every-day schedule. The helper // returns null for those cases; truthy config flows through // the helper's defensive defaults. const parsed = autoSyncWeeklyFromTrigger(auto.trigger_config); if (parsed) { weeklySchedules[playlistId] = { automation_id: auto.id, automation_name: auto.name, time: parsed.time, days: parsed.days, tz: parsed.tz, enabled: auto.enabled !== false && auto.enabled !== 0, owned: true, next_run: auto.next_run, trigger_config: auto.trigger_config || {}, }; return; } } automationPipelines.push(auto); }); return { playlists, automations, playlistSchedules, weeklySchedules, automationPipelines, runHistory: historyData.history || [], runHistoryTotal: historyData.total || 0, }; } async function openAutoSyncScheduleModal() { let overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'auto-sync-schedule-modal'; overlay.className = 'auto-sync-overlay'; document.body.appendChild(overlay); } overlay.innerHTML = `

Auto-Sync Schedule

Drop mirrored playlists onto an interval to schedule refresh, discovery, sync, and wishlist processing.

Loading schedule...
`; overlay.style.display = 'flex'; overlay.onclick = e => { if (e.target === overlay) closeAutoSyncScheduleModal(); }; await refreshAutoSyncScheduleModal(); } function closeAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); stopAutoSyncStatusPolling(); if (overlay) overlay.remove(); } async function refreshAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; try { const [playlistRes, automationRes, historyRes] = await Promise.all([ fetch('/api/mirrored-playlists'), fetch('/api/automations'), fetch(`/api/playlist-pipeline/history?limit=${_autoSyncHistoryLimit}`), ]); const playlists = await playlistRes.json(); const automations = await automationRes.json(); const historyData = await historyRes.json(); if (!playlistRes.ok || playlists.error) throw new Error(playlists.error || 'Failed to load mirrored playlists'); if (!automationRes.ok || automations.error) throw new Error(automations.error || 'Failed to load automations'); if (!historyRes.ok || historyData.error) throw new Error(historyData.error || 'Failed to load pipeline run history'); _autoSyncScheduleState = buildAutoSyncScheduleState(playlists, automations, historyData); renderAutoSyncScheduleModal(); manageAutoSyncStatusPolling(); } catch (err) { overlay.innerHTML = `

Auto-Sync Schedule

Could not load schedule data.

${_esc((err && err.message) || String(err))}
`; } } function renderAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; // Preserve the visible lane board's scroll across the full re-render so dropping / // removing a playlist doesn't snap it back to the top (the user used to have to scroll // back). Targets the ACTIVE tab so it works for both the hourly + weekly boards. const _prevLanes = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes'); const _prevScroll = _prevLanes ? _prevLanes.scrollTop : null; const { playlists, playlistSchedules, weeklySchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState; const scheduledCount = Object.keys(playlistSchedules).length + Object.keys(weeklySchedules || {}).length; const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length + Object.values(weeklySchedules || {}).filter(s => s.enabled).length; const pipelineCount = automationPipelines.length; const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0); const scheduleActive = _autoSyncActiveTab === 'schedule'; const weeklyActive = _autoSyncActiveTab === 'weekly'; const automationsActive = _autoSyncActiveTab === 'automations'; const historyActive = _autoSyncActiveTab === 'history'; const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules); const weeklyPanel = renderAutoSyncWeeklyPanel(playlists, playlistSchedules); const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists); const historyPanel = renderAutoSyncHistoryPanel(runHistory, runHistoryTotal); const monitor = renderAutoSyncPipelineMonitor(playlists); overlay.innerHTML = `
Playlist automation

Auto-Sync Manager

Schedule mirrored playlists through the same playlist-pipeline engine used by Automations.

${scheduledCount}scheduled playlists
${enabledCount}active schedules
${pipelineCount}automation pipelines
${totalTracks}mirrored tracks
${monitor}
${schedulePanel}
${weeklyPanel}
${automationPanel}
${historyPanel}
`; populateAutoSyncHistoryList(overlay); bindAutoSyncHistoryCardInteractions(overlay); if (_prevScroll != null) { const nl = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes'); if (nl) nl.scrollTop = _prevScroll; } } function setAutoSyncTab(tab) { const allowed = ['schedule', 'weekly', 'automations', 'history']; _autoSyncActiveTab = allowed.includes(tab) ? tab : 'schedule'; // Switching tabs closes any open weekly editor so the popover // doesn't ghost-render over the wrong panel. if (_autoSyncActiveTab !== 'weekly') _autoSyncWeeklyEditor = null; renderAutoSyncScheduleModal(); } function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { const filter = (_autoSyncSidebarFilter || '').trim().toLowerCase(); const matchesFilter = (p) => !filter || (p.name || '').toLowerCase().includes(filter) || autoSyncSourceLabel(p.source || '').toLowerCase().includes(filter); const schedulablePlaylists = playlists.filter(p => autoSyncCanSchedulePlaylist(p) && matchesFilter(p)); const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p) && matchesFilter(p)); const grouped = schedulablePlaylists.reduce((acc, p) => { const key = p.source || 'other'; if (!acc[key]) acc[key] = []; acc[key].push(p); return acc; }, {}); const sourceKeys = Object.keys(grouped).sort((a, b) => autoSyncSourceLabel(a).localeCompare(autoSyncSourceLabel(b))); const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => `
${autoSyncSourceIconHtml(source)} ${_esc(autoSyncSourceLabel(source))}
${grouped[source].map(p => { const schedule = playlistSchedules[p.id]; const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; return `
${_esc(p.name)}
${p.track_count || 0} tracks · ${_esc(assigned)}
`; }).join('')}
`).join('') : '
No refreshable mirrored playlists yet.
'; const unavailableHtml = unavailablePlaylists.length ? `
Not schedulable
${unavailablePlaylists.map(p => `
${_esc(p.name)}
${_esc(autoSyncSourceLabel(p.source))} · refresh not supported
`).join('')}
` : ''; // 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); // Concept 1 — interval LANES (horizontal rows) instead of columns: no side-scroll, // empty intervals collapse to thin strips, busy ones grow. Same drag handlers + card. const bucketHtml = allBuckets.map(hours => { const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); const isCustom = !AUTO_SYNC_BUCKETS.includes(hours); const filled = assigned.length > 0; return `
${autoSyncBucketLabel(hours)} ${_esc(autoSyncLaneCadence(hours))}${isCustom ? ' · custom' : ''} ${filled ? `${assigned.length}` : ''}
${filled ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : `
+ Drag a playlist here to sync ${_esc(autoSyncIntervalLabel(hours).toLowerCase())}
`}
`; }).join(''); const filterValue = _esc(_autoSyncSidebarFilter || ''); return `
Drag playlists into an interval Each placement creates or updates an Auto-Sync-owned playlist-pipeline automation.
${bucketHtml}
`; } // ────────────────────────────────────────────────────────────────────── // Weekly schedule board — PR 3 of the schedule-types feature. // Renders 7 day columns Mon-Sun. Each column lists the playlists // scheduled to run on that weekday (multi-day schedules render // under EACH matching column, matching how the user thinks about // "this playlist runs on Mon AND Wed AND Fri"). Drag a playlist onto // a column → create a single-day weekly schedule at the default time. // Click a scheduled card → opens an editor popover for time + days // + tz adjustments. // ────────────────────────────────────────────────────────────────────── function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) { const weeklySchedules = _autoSyncScheduleState.weeklySchedules || {}; const filter = (_autoSyncSidebarFilter || '').trim().toLowerCase(); const matchesFilter = (p) => !filter || (p.name || '').toLowerCase().includes(filter) || autoSyncSourceLabel(p.source || '').toLowerCase().includes(filter); const schedulablePlaylists = playlists.filter(p => autoSyncCanSchedulePlaylist(p) && matchesFilter(p)); const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p) && matchesFilter(p)); const grouped = schedulablePlaylists.reduce((acc, p) => { const key = p.source || 'other'; if (!acc[key]) acc[key] = []; acc[key].push(p); return acc; }, {}); const sourceKeys = Object.keys(grouped).sort((a, b) => autoSyncSourceLabel(a).localeCompare(autoSyncSourceLabel(b))); const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => `
${autoSyncSourceIconHtml(source)} ${_esc(autoSyncSourceLabel(source))}
${grouped[source].map(p => { const weekly = weeklySchedules[p.id]; const hourly = playlistSchedules[p.id]; let assigned = 'Unscheduled'; if (weekly) assigned = autoSyncWeeklyLabel(weekly); else if (hourly) assigned = `Hourly (${autoSyncIntervalLabel(hourly.hours).toLowerCase()})`; return `
${_esc(p.name)}
${p.track_count || 0} tracks · ${_esc(assigned)}
`; }).join('')}
`).join('') : '
No refreshable mirrored playlists yet.
'; const unavailableHtml = unavailablePlaylists.length ? `
Not schedulable
${unavailablePlaylists.map(p => `
${_esc(p.name)}
${_esc(autoSyncSourceLabel(p.source))} · refresh not supported
`).join('')}
` : ''; // Build per-day column lists. Iterate the weeklySchedules dict // once instead of per-day scanning so multi-day schedules render // under each matching column without a double-loop. const playlistsById = new Map(schedulablePlaylists.map(p => [parseInt(p.id, 10), p])); const cardsByDay = {}; AUTO_SYNC_WEEKDAYS.forEach(d => { cardsByDay[d] = []; }); Object.entries(weeklySchedules).forEach(([pid, sched]) => { const playlist = playlistsById.get(parseInt(pid, 10)); if (!playlist) return; (sched.days || []).forEach(day => { if (cardsByDay[day]) cardsByDay[day].push({ playlist, schedule: sched }); }); }); // Day LANES (horizontal rows, Mon–Sun) — mirrors the hourly board's lane layout. const dayColumnsHtml = AUTO_SYNC_WEEKDAYS.map(day => { const cards = cardsByDay[day]; const filled = cards.length > 0; const cardHtml = filled ? cards.map(({ playlist, schedule }) => autoSyncWeeklyCardHtml(playlist, schedule)).join('') : `
+ Drag a playlist here to sync every ${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])}
`; return `
${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])} Weekly ${filled ? `${cards.length}` : ''}
${cardHtml}
`; }).join(''); const filterValue = _esc(_autoSyncSidebarFilter || ''); const editorHtml = _autoSyncWeeklyEditor ? renderAutoSyncWeeklyEditor() : ''; return `
Drag playlists onto a day Each placement creates a weekly-time schedule. Click a card to edit time, additional days, or timezone.
${dayColumnsHtml}
${editorHtml} `; } function autoSyncWeeklyCardHtml(playlist, schedule) { // Mirror the hourly board's ``autoSyncScheduledCardHtml`` shape so // the two boards stay visually consistent — same name + meta + // timing + actions row regardless of whether the schedule is // hourly or weekday-based. The only per-board differences are: // - timing line: weekly label vs interval label // - click opens the weekly editor (hourly board has no editor) // - drag fns use the weekly-specific ondragstart / ondragend // - unschedule calls the weekly-specific helper const enabled = schedule?.enabled !== false; const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; const isRunning = playlist.pipeline_state?.status === 'running'; const health = autoSyncPlaylistHealth(playlist.id); const healthClass = health.level === 'failing' ? 'failing' : health.level === 'warning' ? 'warning' : ''; const label = autoSyncWeeklyLabel(schedule); const tz = schedule?.tz || 'UTC'; return `
${health.level !== 'ok' ? `${health.level === 'failing' ? '!' : '⚠'}` : ''} ${_esc(playlist.name)}
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
${autoSyncOrganizeToggleHtml(playlist)}
${_esc(label)} ${_esc(tz)} ${nextLabel ? `${_esc(nextLabel)}` : ''}
`; } function renderAutoSyncWeeklyEditor() { const draft = _autoSyncWeeklyEditor; if (!draft) return ''; const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(draft.playlistId, 10)); if (!playlist) return ''; const dayToggles = AUTO_SYNC_WEEKDAYS.map(day => { const on = draft.days.includes(day); return ` `; }).join(''); const existing = _autoSyncScheduleState.weeklySchedules?.[draft.playlistId]; return `

Weekly schedule

${_esc(playlist.name)}
${dayToggles}
e.g. America/Los_Angeles, Europe/London, Asia/Tokyo
${existing ? `` : ''}
`; } function setAutoSyncSidebarFilter(value) { _autoSyncSidebarFilter = String(value || ''); // Only re-render the sidebar/board portion to keep input focus. const panel = document.getElementById('auto-sync-schedule-panel'); if (!panel) return; const { playlists, playlistSchedules } = _autoSyncScheduleState; panel.innerHTML = renderAutoSyncSchedulePanel(playlists, playlistSchedules); // Restore focus to the search input + caret position at end. const input = panel.querySelector('.auto-sync-sidebar-search'); if (input) { input.focus(); try { input.setSelectionRange(value.length, value.length); } catch (_) {} } } function getAutoSyncPipelinePlaylists(playlists) { return playlists .map(p => ({ playlist: p, state: p.pipeline_state || null })) .filter(item => item.state && item.state.status && item.state.status !== 'idle') .sort((a, b) => { const aRunning = a.state.status === 'running' ? 1 : 0; const bRunning = b.state.status === 'running' ? 1 : 0; if (aRunning !== bRunning) return bRunning - aRunning; return (b.state.finished_at || b.state.started_at || 0) - (a.state.finished_at || a.state.started_at || 0); }); } function autoSyncPipelineStatusLabel(status) { if (status === 'running') return 'Running'; if (status === 'finished') return 'Completed'; if (status === 'skipped') return 'Skipped'; if (status === 'error') return 'Needs attention'; return 'Idle'; } function autoSyncPipelineStatusClass(status) { if (status === 'running') return 'running'; if (status === 'finished') return 'finished'; if (status === 'error' || status === 'skipped') return 'error'; return 'idle'; } function renderAutoSyncPipelineMonitor(playlists) { const pipelineItems = getAutoSyncPipelinePlaylists(playlists); const running = pipelineItems.filter(item => item.state.status === 'running'); const recent = pipelineItems.filter(item => item.state.status !== 'running').slice(0, 2); const visible = [...running, ...recent].slice(0, 4); const title = running.length ? `${running.length} pipeline${running.length === 1 ? '' : 's'} running` : 'No pipelines running'; const detail = running.length ? 'Live status refreshes while this modal is open.' : 'Use Run now on a scheduled playlist when you want the pipeline immediately.'; return `
Live pipeline monitor ${_esc(title)} ${_esc(detail)}
${visible.length ? `
${visible.map(({ playlist, state }) => autoSyncPipelineMonitorCardHtml(playlist, state)).join('')}
` : '
ReadyScheduled playlists appear here while the all-in-one pipeline runs.
'}
`; } function autoSyncPipelineMonitorCardHtml(playlist, state) { const status = state.status || 'idle'; const progress = Math.max(0, Math.min(100, parseInt(state.progress, 10) || 0)); const latest = Array.isArray(state.log) && state.log.length ? state.log[state.log.length - 1].message : ''; const phase = state.phase || autoSyncPipelineStatusLabel(status); return `
${_esc(playlist.name || `Playlist #${playlist.id}`)} ${_esc(autoSyncPipelineStatusLabel(status))}
${_esc(phase)}
${latest ? `${_esc(latest)}` : ''}
`; } function renderAutoSyncAutomationPanel(automationPipelines, playlists) { if (!automationPipelines.length) { return '
No Automations-page playlist pipelines found.
'; } return `
Read-only Automations-page pipelines These use the playlist pipeline but are managed from the Automations page, so this modal only displays them.
${automationPipelines.map(auto => autoSyncAutomationCardHtml(auto, playlists)).join('')}
`; } function renderAutoSyncHistoryPanel(history, total) { if (!history.length) { return `
No playlist pipeline runs yet Future Auto-Sync and playlist pipeline runs will record before/after playlist snapshots here.
`; } const filter = _autoSyncHistoryFilter || 'all'; const tabs = [ ['all', 'All', history.length], ['error', 'Errors', history.filter(h => h.status === 'error' || h.status === 'skipped').length], ['completed', 'Completed', history.filter(h => h.status === 'completed' || h.status === 'finished').length], ]; const filterTabsHtml = tabs.map(([key, label, count]) => ` `).join(''); const canLoadMore = total > history.length; return `
Playlist pipeline run history Each run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing.
${filterTabsHtml}
Preparing run history...
${canLoadMore ? `
` : ''} `; } function setAutoSyncHistoryFilter(key) { _autoSyncHistoryFilter = ['error', 'completed'].includes(key) ? key : 'all'; renderAutoSyncScheduleModal(); } function loadMoreAutoSyncHistory() { _autoSyncHistoryLimit = Math.min(500, _autoSyncHistoryLimit + 50); 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; const allHistory = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : []; const total = _autoSyncScheduleState.runHistoryTotal || 0; const filter = _autoSyncHistoryFilter || 'all'; const history = allHistory.filter(h => { if (filter === 'error') return h.status === 'error' || h.status === 'skipped'; if (filter === 'completed') return h.status === 'completed' || h.status === 'finished'; return true; }); list.innerHTML = ''; list.dataset.renderer = 'dom-cards'; list.dataset.renderedCount = '0'; if (filter !== 'all' && !history.length) { const note = document.createElement('div'); note.className = 'auto-sync-history-empty'; const strong = document.createElement('strong'); strong.textContent = filter === 'error' ? 'No failed runs in the loaded window' : 'No completed runs in the loaded window'; const small = document.createElement('span'); small.textContent = 'Switch filters or load more history.'; note.append(strong, small); list.appendChild(note); return; } history.forEach((entry, index) => { try { list.appendChild(createAutoSyncHistoryEntryElement(entry, index)); } catch (err) { 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'; totalEl.textContent = `Showing ${history.length} of ${total} runs`; list.appendChild(totalEl); } } 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'; const row = document.createElement('div'); row.className = 'auto-sync-history-row'; 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 title = document.createElement('div'); title.className = 'auto-sync-history-title-row'; const dot = document.createElement('span'); dot.className = 'auto-sync-card-status-dot disabled'; const name = document.createElement('strong'); name.textContent = entry?.playlist_name || `Run #${entry?.id || index + 1}`; const badge = document.createElement('span'); badge.className = 'auto-sync-history-status error'; badge.textContent = 'Render error'; title.append(dot, name, badge); const summary = document.createElement('small'); summary.textContent = err?.message || 'This run history row could not be rendered.'; titleBlock.append(title, summary); head.appendChild(titleBlock); row.appendChild(head); card.appendChild(row); return card; } function createAutoSyncHistoryEntryElement(entry, index = 0) { entry = autoSyncNormalizeHistoryEntry(entry, index); const status = entry.status || 'completed'; const before = entry.before_json || {}; const after = entry.after_json || {}; const result = entry.result_json || {}; const started = entry.started_at ? _autoTimeAgo(entry.started_at) : ''; const duration = entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : ''; const trackDelta = autoSyncDelta(after.track_count, before.track_count); const discoveredDelta = autoSyncDelta(after.discovered_count, before.discovered_count); const wishlistDelta = autoSyncDelta(after.wishlisted_count, before.wishlisted_count); 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 triggerSource = entry.trigger_source || 'pipeline'; const card = document.createElement('article'); card.className = `auto-sync-history-entry ${status === 'completed' || status === 'finished' ? '' : 'auto-sync-history-entry-' + status}`.trim(); card.id = `${entryId}-card`; card.dataset.historyEntry = entryId; const row = document.createElement('div'); row.className = 'auto-sync-history-row'; row.setAttribute('role', 'button'); row.tabIndex = 0; row.setAttribute('aria-expanded', 'false'); row.setAttribute('aria-controls', entryId); row.dataset.historyToggle = entryId; const dot = document.createElement('span'); dot.className = `auto-sync-card-status-dot ${autoSyncHistoryStatusClass(status)}`; 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-history-flow'; autoSyncAppendFlowChip(flow, triggerSource, 'flow-trigger'); autoSyncAppendFlowArrow(flow); autoSyncAppendFlowChip(flow, 'Refresh', 'flow-action'); autoSyncAppendFlowArrow(flow); autoSyncAppendFlowChip(flow, 'Discover', 'flow-action'); autoSyncAppendFlowArrow(flow); autoSyncAppendFlowChip(flow, 'Sync + wishlist', 'flow-notify'); 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 }); 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; span.textContent = text; parent.appendChild(span); } function autoSyncAppendFlowArrow(parent) { const span = document.createElement('span'); span.className = 'flow-arrow'; span.textContent = '->'; parent.appendChild(span); } function autoSyncNormalizeHistoryEntry(entry, index) { if (!entry || typeof entry !== 'object') { return { id: `unknown-${index}`, status: 'completed', playlist_name: 'Playlist pipeline run', trigger_source: 'pipeline', summary: 'Run history entry did not include detailed metadata.', before_json: {}, after_json: {}, result_json: {}, }; } return { ...entry, id: entry.id ?? `history-${index}`, before_json: autoSyncParseHistoryObject(entry.before_json), after_json: autoSyncParseHistoryObject(entry.after_json), result_json: autoSyncParseHistoryObject(entry.result_json), }; } function bindAutoSyncHistoryCardInteractions(root = document) { root.querySelectorAll('[data-history-toggle]').forEach(row => { const entryId = row.dataset.historyToggle; row.addEventListener('click', () => autoSyncToggleHistoryEntry(entryId)); row.addEventListener('keydown', event => autoSyncHistoryEntryKeydown(event, entryId)); }); root.querySelectorAll('[data-history-toggle-button]').forEach(button => { const entryId = button.dataset.historyToggleButton; button.addEventListener('click', event => { event.stopPropagation(); autoSyncToggleHistoryEntry(entryId); }); }); } function autoSyncParseHistoryObject(value) { if (!value) return {}; if (typeof value === 'object') return value; if (typeof value !== 'string') return {}; try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : {}; } catch (_err) { return {}; } } function autoSyncHistoryFallbackSummary(before, after, status) { const beforeTracks = parseInt(before.track_count, 10) || 0; const afterTracks = parseInt(after.track_count, 10) || 0; return `${autoSyncHistoryStatusLabel(status)} | ${beforeTracks} -> ${afterTracks} tracks`; } function autoSyncToggleHistoryEntry(entryId) { const el = document.getElementById(entryId); const card = document.getElementById(`${entryId}-card`); const row = card?.querySelector('.auto-sync-history-row'); if (!el) return; const expanded = el.classList.toggle('expanded'); if (card) card.classList.toggle('expanded', expanded); if (row) row.setAttribute('aria-expanded', expanded ? 'true' : 'false'); } function autoSyncHistoryEntryKeydown(event, entryId) { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); autoSyncToggleHistoryEntry(entryId); } function autoSyncHistoryStatusLabel(status) { if (status === 'completed' || status === 'finished') return 'Completed'; if (status === 'error') return 'Error'; if (status === 'skipped') return 'Skipped'; return status || 'Run'; } function autoSyncHistoryStatusClass(status) { if (status === 'completed' || status === 'finished') return 'enabled'; if (status === 'error' || status === 'skipped') return 'disabled'; return 'enabled'; } function autoSyncDurationLabel(seconds) { const total = Math.max(0, Math.round(parseFloat(seconds) || 0)); if (total < 60) return `${total}s`; const mins = Math.floor(total / 60); const secs = total % 60; return `${mins}m ${secs}s`; } function autoSyncDelta(after, before) { const a = parseInt(after, 10) || 0; const b = parseInt(before, 10) || 0; return a - b; } function autoSyncHistoryStatHtml(label, before, after, delta) { const beforeValue = parseInt(before, 10) || 0; const afterValue = parseInt(after, 10) || 0; const deltaText = delta ? ` (${delta > 0 ? '+' : ''}${delta})` : ''; return `
${_esc(label)} ${beforeValue} -> ${afterValue}${_esc(deltaText)}
`; } function autoSyncHistoryPreviewPill(label, before, after, delta) { return `${_esc(autoSyncHistoryPreviewText(label, before, after, delta))}`; } function autoSyncHistoryPreviewText(label, before, after, delta) { const beforeValue = parseInt(before, 10) || 0; const afterValue = parseInt(after, 10) || 0; const deltaText = delta ? ` ${delta > 0 ? '+' : ''}${delta}` : ''; return `${label} ${beforeValue}->${afterValue}${deltaText}`; } function autoSyncHistoryResultPill(label, value) { if (value === undefined || value === null || value === '') return ''; return `${_esc(label)}: ${_esc(String(value))}`; } function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) { 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) : '—'], ['Source', entry.source || after.source || before.source || '—'], ].map(([k, v]) => `
${_esc(k)}${_esc(autoSyncValueLabel(v))}
`).join(''); // `tracks_discovered` was a status STRING (e.g. "completed"), not a // count — kept it out of the pills so the panel doesn't show a // confusing "Discovered: completed" chip. Same data is already // surfaced as a before/after stat card above. const resultPills = [ ['Refreshed', result.playlists_refreshed], ['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); const playlistId = entry.playlist_id || after.playlist_id || before.playlist_id || ''; const playlistName = entry.playlist_name || after.name || before.name || ''; const runAgainHtml = playlistId ? `
` : ''; return `
${statsHtml}
${factsHtml}
${resultPills ? `
${resultPills}
` : ''} ${runAgainHtml} ${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 `
${_esc(label)} ${_esc(autoSyncValueLabel(value))}
`; } function autoSyncHistorySnapshotHtml(title, snapshot) { const fields = [ ['Name', snapshot.name], ['Source', snapshot.source], ['Tracks', snapshot.track_count], ['Discovered', snapshot.discovered_count], ['Wishlisted', snapshot.wishlisted_count], ['In library', snapshot.in_library_count], ]; return `
${_esc(title)}
${fields.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')}
`; } function autoSyncHistoryObjectHtml(title, obj, options = {}) { if (!obj || typeof obj !== 'object') return ''; const entries = Object.entries(obj) .filter(([key, value]) => !(options.skipPrivate && key.startsWith('_')) && value !== undefined && value !== null && value !== '') .slice(0, 24); if (!entries.length) return ''; return `
${_esc(title)}
${entries.map(([key, value]) => `
${_esc(autoSyncHumanizeKey(key))} ${_esc(autoSyncValueLabel(value))}
`).join('')}
`; } function autoSyncHistoryLogsHtml(logLines) { if (!Array.isArray(logLines) || !logLines.length) return ''; return `
Run Log
${logLines.slice(-12).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('')}
`; } function autoSyncFormatDateTime(value) { if (!value) return ''; const ts = _autoParseUTC(value); if (!Number.isFinite(ts)) return value; return new Date(ts).toLocaleString(); } function autoSyncHumanizeKey(key) { return String(key || '') .replace(/^_+/, '') .replace(/_/g, ' ') .replace(/\b\w/g, ch => ch.toUpperCase()); } function autoSyncValueLabel(value) { if (value === undefined || value === null || value === '') return 'Not recorded'; if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (Array.isArray(value)) return value.length ? value.map(autoSyncValueLabel).join(', ') : 'None'; if (typeof value === 'object') return JSON.stringify(value); return String(value); } function autoSyncAutomationCardHtml(auto, playlists) { const cfg = auto.action_config || {}; const playlistId = autoSyncPlaylistIdFromAutomation(auto); const playlist = playlistId ? playlists.find(p => parseInt(p.id, 10) === playlistId) : null; const target = cfg.all === true || cfg.all === 'true' ? 'All refreshable mirrored playlists' : playlist ? playlist.name : playlistId ? `Playlist #${playlistId}` : 'Custom pipeline target'; const trigger = _autoFormatTrigger(auto.trigger_type, auto.trigger_config || {}); const enabled = auto.enabled !== false && auto.enabled !== 0; const next = auto.next_run ? autoSyncNextRunLabel(auto.next_run) : 'not scheduled'; const sourceLabel = playlist ? autoSyncSourceLabel(playlist.source) : (cfg.all === true || cfg.all === 'true' ? 'All sources' : 'Pipeline'); return `
${_esc(auto.name || 'Playlist Pipeline')}
${_esc(trigger)} Playlist pipeline Refresh + sync
${enabled ? 'Enabled' : 'Disabled'} ${_esc(sourceLabel)} ${_esc(target)} ${_esc(next)}
Read only
`; } function autoSyncOrganizeToggleHtml(playlist) { const checked = playlist.organize_by_playlist ? 'checked' : ''; return ` `; } async function setAutoSyncOrganizeByPlaylist(playlistId, enabled) { try { const res = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ organize_by_playlist: !!enabled }), }); const data = await res.json(); if (!res.ok || data.error) throw new Error(data.error || 'Failed to update preference'); const pl = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (pl) pl.organize_by_playlist = !!enabled; showToast(enabled ? 'Auto-Sync will use playlist folders' : 'Auto-Sync will use standard download layout', 'success'); } catch (err) { showToast(`Error: ${err.message}`, 'error'); await refreshAutoSyncScheduleModal(); } } function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; const isRunning = playlist.pipeline_state?.status === 'running'; const health = autoSyncPlaylistHealth(playlist.id); const healthClass = health.level === 'failing' ? 'failing' : health.level === 'warning' ? 'warning' : ''; return `
${health.level !== 'ok' ? `${health.level === 'failing' ? '!' : '⚠'}` : ''} ${_esc(playlist.name)}
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
${autoSyncOrganizeToggleHtml(playlist)}
${_esc(autoSyncIntervalLabel(schedule?.hours || 24))} ${nextLabel ? `${_esc(nextLabel)}` : ''}
`; } 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); if (!Number.isFinite(ts)) return ''; const diff = ts - Date.now(); if (diff <= 0) return 'due now'; const mins = Math.ceil(diff / 60000); if (mins < 60) return `next in ${mins}m`; const hours = Math.ceil(mins / 60); if (hours < 24) return `next in ${hours}h`; return `next in ${Math.ceil(hours / 24)}d`; } function autoSyncDragStart(event) { const playlistId = event.currentTarget?.dataset?.playlistId; if (!playlistId) return; _autoSyncIsDragging = true; event.dataTransfer.setData('text/plain', playlistId); event.dataTransfer.effectAllowed = 'move'; } function autoSyncDragOver(event) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; const col = event.currentTarget; if (col && !col.classList.contains('drag-over')) { col.classList.add('drag-over'); } } function autoSyncDragLeave(event) { const col = event.currentTarget; if (!col) return; if (col.contains(event.relatedTarget)) return; col.classList.remove('drag-over'); } async function autoSyncDrop(event, hours) { event.preventDefault(); _autoSyncIsDragging = false; const col = event.currentTarget; if (col) col.classList.remove('drag-over'); const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10); if (!playlistId) return; await saveAutoSyncPlaylistSchedule(playlistId, hours); } function autoSyncDragEnd() { _autoSyncIsDragging = false; } async function saveAutoSyncPlaylistSchedule(playlistId, hours) { const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!playlist) return; if (!autoSyncCanSchedulePlaylist(playlist)) { showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info'); return; } // Enforce one-schedule-per-playlist: if a weekly schedule exists, // drop it before installing the hourly one. Mirrors the same // mutual-exclusion the weekly save path enforces in reverse. const existingWeekly = _autoSyncScheduleState.weeklySchedules?.[playlistId]; if (existingWeekly) { try { await fetch(`/api/automations/${existingWeekly.automation_id}`, { method: 'DELETE' }); } catch (_) { /* best-effort cleanup */ } } 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', }; try { 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 to save Auto-Sync schedule'); showToast(`${playlist.name} scheduled every ${autoSyncBucketLabel(hours)}`, 'success'); await refreshAutoSyncScheduleModal(); } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } async function unscheduleAutoSyncPlaylist(playlistId) { const schedule = _autoSyncScheduleState.playlistSchedules[playlistId]; const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!schedule) return; if (!await showConfirmDialog({ title: 'Remove Auto-Sync', message: `Remove Auto-Sync schedule for "${playlist?.name || 'this playlist'}"?` })) return; try { const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' }); const data = await res.json(); if (!res.ok || data.error) throw new Error(data.error || 'Failed to remove Auto-Sync schedule'); showToast('Auto-Sync schedule removed', 'success'); await refreshAutoSyncScheduleModal(); } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } // ────────────────────────────────────────────────────────────────────── // Weekly-tab drag-drop + editor state mutators. // ────────────────────────────────────────────────────────────────────── function autoSyncWeeklyDragStart(event) { _autoSyncIsDragging = true; const id = event.currentTarget?.dataset?.playlistId || ''; event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; } function autoSyncWeeklyDragEnd() { _autoSyncIsDragging = false; } function autoSyncWeeklyDragOver(event) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; const col = event.currentTarget; if (col && !col.classList.contains('drag-over')) { col.classList.add('drag-over'); } } function autoSyncWeeklyDragLeave(event) { const col = event.currentTarget; if (!col) return; col.classList.remove('drag-over'); } async function autoSyncWeeklyDrop(event, day) { event.preventDefault(); _autoSyncIsDragging = false; const col = event.currentTarget; if (col) col.classList.remove('drag-over'); const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10); if (!playlistId) return; if (!AUTO_SYNC_WEEKDAYS.includes(day)) return; const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === playlistId); if (!playlist || !autoSyncCanSchedulePlaylist(playlist)) { showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info'); return; } // Augment OR create: if a weekly schedule already exists for this // playlist, append the dropped day (no-op when already present); // otherwise create a single-day schedule with default time + browser tz. const existing = _autoSyncScheduleState.weeklySchedules?.[playlistId]; const days = existing ? (existing.days.includes(day) ? existing.days : [...existing.days, day]) : [day]; const time = existing?.time || '09:00'; const tz = existing?.tz || detectBrowserTimezone(); await saveAutoSyncWeeklySchedule(playlistId, { time, days, tz }); } function openAutoSyncWeeklyEditor(playlistId) { const pid = parseInt(playlistId, 10); if (!pid) return; const existing = _autoSyncScheduleState.weeklySchedules?.[pid]; _autoSyncWeeklyEditor = { playlistId: pid, time: existing?.time || '09:00', days: existing ? [...existing.days] : [], tz: existing?.tz || detectBrowserTimezone(), }; renderAutoSyncScheduleModal(); } function closeAutoSyncWeeklyEditor() { _autoSyncWeeklyEditor = null; renderAutoSyncScheduleModal(); } function toggleAutoSyncWeeklyEditorDay(day) { if (!_autoSyncWeeklyEditor) return; if (!AUTO_SYNC_WEEKDAYS.includes(day)) return; const idx = _autoSyncWeeklyEditor.days.indexOf(day); if (idx >= 0) { _autoSyncWeeklyEditor.days.splice(idx, 1); } else { _autoSyncWeeklyEditor.days.push(day); } renderAutoSyncScheduleModal(); } function setAutoSyncWeeklyEditorTime(value) { if (!_autoSyncWeeklyEditor) return; _autoSyncWeeklyEditor.time = String(value || '09:00'); } function setAutoSyncWeeklyEditorTz(value) { if (!_autoSyncWeeklyEditor) return; _autoSyncWeeklyEditor.tz = String(value || 'UTC'); } async function saveAutoSyncWeeklyFromEditor() { if (!_autoSyncWeeklyEditor) return; const { playlistId, time, days, tz } = _autoSyncWeeklyEditor; if (!days.length) { showToast('Pick at least one day for the weekly schedule.', 'error'); return; } await saveAutoSyncWeeklySchedule(playlistId, { time, days, tz }); _autoSyncWeeklyEditor = null; } async function unscheduleAutoSyncWeeklyFromEditor() { if (!_autoSyncWeeklyEditor) return; const { playlistId } = _autoSyncWeeklyEditor; _autoSyncWeeklyEditor = null; await unscheduleAutoSyncWeekly(playlistId); } async function saveAutoSyncWeeklySchedule(playlistId, { time, days, tz }) { const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!playlist) return; if (!autoSyncCanSchedulePlaylist(playlist)) { showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info'); return; } const triggerConfig = autoSyncWeeklyTrigger({ time, days, tz }); if (!triggerConfig.days.length) { showToast('Pick at least one day for the weekly schedule.', 'error'); return; } // Enforce one-schedule-per-playlist: if the playlist currently has // an hourly schedule, drop it before installing the weekly one. The // engine can technically run both side-by-side as two separate // automations, but the UI assumes one schedule per playlist and // showing two cards under the same playlist row would surprise // users. Delete-then-create is safe — the worst case (POST fails) // leaves the playlist unscheduled, which is recoverable from the UI. const existingHourly = _autoSyncScheduleState.playlistSchedules?.[playlistId]; if (existingHourly) { try { await fetch(`/api/automations/${existingHourly.automation_id}`, { method: 'DELETE' }); } catch (_) { /* best-effort cleanup */ } } const existingWeekly = _autoSyncScheduleState.weeklySchedules?.[playlistId]; const payload = { name: `Auto-Sync: ${playlist.name}`, trigger_type: 'weekly_time', trigger_config: triggerConfig, action_type: 'playlist_pipeline', action_config: { playlist_id: String(playlistId), all: false }, then_actions: [], group_name: 'Playlist Auto-Sync', owned_by: 'auto_sync', }; try { const res = await fetch(existingWeekly ? `/api/automations/${existingWeekly.automation_id}` : '/api/automations', { method: existingWeekly ? '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 to save weekly schedule'); showToast(`${playlist.name} scheduled ${autoSyncWeeklyLabel(triggerConfig).toLowerCase()}`, 'success'); await refreshAutoSyncScheduleModal(); } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } async function unscheduleAutoSyncWeekly(playlistId) { const schedule = _autoSyncScheduleState.weeklySchedules?.[playlistId]; const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!schedule) return; if (!await showConfirmDialog({ title: 'Remove Weekly Schedule', message: `Remove weekly schedule for "${playlist?.name || 'this playlist'}"?` })) return; try { const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' }); const data = await res.json(); if (!res.ok || data.error) throw new Error(data.error || 'Failed to remove weekly schedule'); showToast('Weekly schedule removed', 'success'); await refreshAutoSyncScheduleModal(); } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } async function runAutoSyncScheduledPlaylist(playlistId) { const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!playlist) return; await runMirroredPlaylistPipeline(playlistId, playlist.name || `Playlist #${playlistId}`); await refreshAutoSyncScheduleModal(); } function manageAutoSyncStatusPolling() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) { stopAutoSyncStatusPolling(); return; } const hasRunning = _autoSyncScheduleState.playlists.some(p => p.pipeline_state?.status === 'running'); if (!hasRunning) { stopAutoSyncStatusPolling(); return; } if (_autoSyncStatusPoller) return; _autoSyncStatusPoller = setInterval(() => { if (_autoSyncIsDragging) return; refreshAutoSyncScheduleModal(); }, 3000); } function stopAutoSyncStatusPolling() { if (!_autoSyncStatusPoller) return; clearInterval(_autoSyncStatusPoller); _autoSyncStatusPoller = null; } async function parseMirroredPipelineResponse(res, fallbackMessage) { const text = await res.text(); let data = {}; if (text) { try { data = JSON.parse(text); } catch (_err) { const detail = res.status === 404 ? 'Auto-Sync endpoint not found. Restart the SoulSync server so the new backend routes load.' : fallbackMessage; throw new Error(detail); } } if (!res.ok || data.error) { throw new Error(data.error || fallbackMessage); } return data; } async function editMirroredCustomName(playlistId, originalName, currentCustom) { // Custom alias: changes the name shown in SoulSync AND used when syncing to // the media server, while staying tied to the original upstream playlist. // Blank clears the alias (falls back to the original name). const nextName = window.prompt( `Rename "${originalName}"\n\nThis name is shown here and used when syncing. ` + `Leave blank to use the original name.`, currentCustom || '' ); if (nextName === null) return; // cancelled const trimmed = nextName.trim(); try { const res = await fetch(`/api/mirrored-playlists/${playlistId}/custom-name`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ custom_name: trimmed }) }); const data = await res.json(); if (!res.ok || data.error) { throw new Error(data.error || 'Failed to update name'); } showToast(trimmed ? `Renamed to "${trimmed}"` : `Reverted to "${originalName}"`, 'success'); loadMirroredPlaylists(); const openModal = document.getElementById('mirrored-track-modal'); if (openModal) { closeMirroredModal(); openMirroredPlaylistModal(playlistId); } } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } async function editMirroredSourceRef(playlistId, name, source, currentRef) { const label = (source === 'spotify_public' || source === 'youtube') ? 'original playlist URL' : 'original playlist ID or URL'; const nextRef = window.prompt(`Update ${label} for "${name}"`, currentRef || ''); if (nextRef === null) return; const trimmed = nextRef.trim(); if (!trimmed) { showToast('Source link or ID is required', 'error'); return; } try { const res = await fetch(`/api/mirrored-playlists/${playlistId}/source-ref`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source_ref: trimmed }) }); const data = await res.json(); if (!res.ok || data.error) { throw new Error(data.error || 'Failed to update source reference'); } showToast(`Updated source for ${name}`, 'success'); loadMirroredPlaylists(); const openModal = document.getElementById('mirrored-track-modal'); if (openModal) { closeMirroredModal(); openMirroredPlaylistModal(playlistId); } } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } function applyMirroredPipelineState(playlistId, state) { const hash = `mirrored_${playlistId}`; const existing = youtubePlaylistStates[hash] || {}; const status = state.status || 'idle'; let phase = existing.phase; if (status === 'running') phase = 'pipeline_running'; else if (status === 'finished') phase = 'pipeline_complete'; else if (status === 'error' || status === 'skipped') phase = 'pipeline_error'; youtubePlaylistStates[hash] = { ...existing, phase, pipeline_status: status, pipeline_progress: state.progress || 0, pipeline_phase: state.phase || '', pipeline_error: state.error || '', pipeline_log: state.log || [], pipeline_result: state.result || null, }; updateMirroredCardPhase(hash, phase); } async function runMirroredPlaylistPipeline(playlistId, name) { try { const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync'); applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); showToast(`Auto-Sync started for ${name}`, 'success'); _autoSyncScheduleState.playlists = _autoSyncScheduleState.playlists.map(p => ( parseInt(p.id, 10) === parseInt(playlistId, 10) ? { ...p, pipeline_state: data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' } } : p )); renderAutoSyncScheduleModal(); manageAutoSyncStatusPolling(); pollMirroredPipelineStatus(playlistId, name); } catch (err) { showToast(`Error: ${err.message}`, 'error'); } } function pollMirroredPipelineStatus(playlistId, name) { const key = `mirrored_${playlistId}`; if (mirroredPipelinePollers[key]) clearInterval(mirroredPipelinePollers[key]); const tick = async () => { try { const res = await fetch(`/api/mirrored-playlists/${playlistId}/pipeline/status`); const state = await parseMirroredPipelineResponse(res, 'Failed to read Auto-Sync status'); applyMirroredPipelineState(playlistId, state); if (state.status === 'finished') { clearInterval(mirroredPipelinePollers[key]); delete mirroredPipelinePollers[key]; showToast(`Auto-Sync complete for ${name}`, 'success'); loadMirroredPlaylists(); refreshAutoSyncScheduleModal(); } else if (state.status === 'error' || state.status === 'skipped') { clearInterval(mirroredPipelinePollers[key]); delete mirroredPipelinePollers[key]; showToast(state.error || `Pipeline stopped for ${name}`, 'error'); loadMirroredPlaylists(); refreshAutoSyncScheduleModal(); } else if (state.status === 'idle') { clearInterval(mirroredPipelinePollers[key]); delete mirroredPipelinePollers[key]; } } catch (err) { clearInterval(mirroredPipelinePollers[key]); delete mirroredPipelinePollers[key]; showToast(`Pipeline status error: ${err.message}`, 'error'); } }; tick(); mirroredPipelinePollers[key] = setInterval(tick, 2500); }