// 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: {}, automationPipelines: [], runHistory: [], runHistoryTotal: 0, }; let _autoSyncActiveTab = 'schedule'; 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'}`; } function autoSyncSourceLabel(source) { const labels = { spotify: 'Spotify', spotify_public: 'Spotify Link', tidal: 'Tidal', youtube: 'YouTube', deezer: 'Deezer', qobuz: 'Qobuz', beatport: 'Beatport', file: 'File Imports', }; return labels[source] || source || 'Other'; } function autoSyncCanSchedulePlaylist(playlist) { return playlist && !['file', 'beatport'].includes(playlist.source || ''); } 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 automationPipelines = []; const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation); pipelineAutomations.forEach(auto => { const playlistId = autoSyncPlaylistIdFromAutomation(auto); const hours = auto.trigger_type === 'schedule' ? autoSyncHoursFromTrigger(auto.trigger_config || {}) : null; if (playlistId && hours && autoSyncIsScheduleOwned(auto)) { 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 || {}, }; } else { automationPipelines.push(auto); } }); return { playlists, automations, playlistSchedules, 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=50'), ]); 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.message)}
`; } } function renderAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; const { playlists, playlistSchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState; const scheduledCount = Object.keys(playlistSchedules).length; const enabledCount = Object.values(playlistSchedules).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 automationsActive = _autoSyncActiveTab === 'automations'; const historyActive = _autoSyncActiveTab === 'history'; const schedulePanel = renderAutoSyncSchedulePanel(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}
${automationPanel}
${historyPanel}
`; } function setAutoSyncTab(tab) { _autoSyncActiveTab = ['automations', 'history'].includes(tab) ? tab : 'schedule'; renderAutoSyncScheduleModal(); } function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { const schedulablePlaylists = playlists.filter(autoSyncCanSchedulePlaylist); const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(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 => `
${_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('')}
` : ''; const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => { const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); return `
${autoSyncBucketLabel(hours)} ${assigned.length} playlist${assigned.length === 1 ? '' : 's'}
${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'}
`; }).join(''); return `
Drag playlists into an interval Each placement creates or updates an Auto-Sync-owned playlist-pipeline automation.
${bucketHtml}
`; } 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.
`; } return `
Playlist pipeline run history Each run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing.
${history.map((entry, index) => autoSyncHistoryEntryHtml(entry, index)).join('')} ${total > history.length ? `
Showing ${history.length} of ${total} runs
` : ''}
`; } function autoSyncHistoryEntryHtml(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 summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status); const resultHtml = [ autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed), autoSyncHistoryResultPill('Synced', result.tracks_synced), autoSyncHistoryResultPill('Skipped', result.sync_skipped), autoSyncHistoryResultPill('Queued', result.wishlist_queued), result.error ? `${_esc(result.error)}` : '', ].filter(Boolean).join(''); return `
${_esc(playlistName)} ${_esc(autoSyncHistoryStatusLabel(status))}
${_esc(entry.trigger_source || 'pipeline')} Refresh Discover Sync + wishlist
${_esc(summary)}
${autoSyncHistoryPreviewPill('Tracks', before.track_count, after.track_count, trackDelta)} ${autoSyncHistoryPreviewPill('Discovered', before.discovered_count, after.discovered_count, discoveredDelta)} ${autoSyncHistoryPreviewPill('Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta)} ${autoSyncHistoryPreviewPill('Library', before.in_library_count, after.in_library_count, libraryDelta)}
${started ? `${_esc(started)}` : ''} ${duration ? `${_esc(duration)}` : ''} ${_esc(entry.trigger_source || 'pipeline')}
${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, trackDelta)} ${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, discoveredDelta)} ${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta)} ${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, libraryDelta)}
${resultHtml || 'No detailed result payload recorded for this run.'}
`; } 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 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); if (el) el.classList.toggle('expanded'); } 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) { 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 autoSyncHistoryResultPill(label, value) { if (value === undefined || value === null || value === '') return ''; return `${_esc(label)}: ${_esc(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 autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; const isRunning = playlist.pipeline_state?.status === 'running'; return `
${_esc(playlist.name)}
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
${_esc(autoSyncIntervalLabel(schedule?.hours || 24))} ${nextLabel ? `${_esc(nextLabel)}` : ''}
`; } 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; } 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'); } } 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 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); }