diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 7e52a709..320113e5 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -386,8 +386,9 @@ let _autoSyncScheduleState = { playlists: [], automations: [], playlistSchedules: {}, - automationManaged: [], + automationPipelines: [], }; +let _autoSyncActiveTab = 'schedule'; /** * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. @@ -637,25 +638,26 @@ function autoSyncIsScheduleOwned(auto) { function buildAutoSyncScheduleState(playlists, automations) { const playlistSchedules = {}; - const automationManaged = []; - automations.filter(autoSyncIsPipelineAutomation).forEach(auto => { + 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) { + if (playlistId && hours && autoSyncIsScheduleOwned(auto)) { playlistSchedules[playlistId] = { automation_id: auto.id, automation_name: auto.name, hours, enabled: auto.enabled !== false && auto.enabled !== 0, - owned: autoSyncIsScheduleOwned(auto), + owned: true, next_run: auto.next_run, trigger_config: auto.trigger_config || {}, }; } else { - automationManaged.push(auto); + automationPipelines.push(auto); } }); - return { playlists, automations, playlistSchedules, automationManaged }; + return { playlists, automations, playlistSchedules, automationPipelines }; } async function openAutoSyncScheduleModal() { @@ -679,7 +681,7 @@ async function openAutoSyncScheduleModal() { `; overlay.style.display = 'flex'; - overlay.addEventListener('click', e => { if (e.target === overlay) closeAutoSyncScheduleModal(); }, { once: true }); + overlay.onclick = e => { if (e.target === overlay) closeAutoSyncScheduleModal(); }; await refreshAutoSyncScheduleModal(); } @@ -719,7 +721,49 @@ function renderAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; - const { playlists, playlistSchedules, automationManaged } = _autoSyncScheduleState; + const { playlists, playlistSchedules, automationPipelines } = _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 schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules); + const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, 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
+
+
+ + +
+
${schedulePanel}
+
${automationPanel}
+
+ `; +} + +function setAutoSyncTab(tab) { + _autoSyncActiveTab = tab === 'automations' ? 'automations' : 'schedule'; + renderAutoSyncScheduleModal(); +} + +function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { const grouped = playlists.reduce((acc, p) => { const key = p.source || 'other'; if (!acc[key]) acc[key] = []; @@ -737,7 +781,7 @@ function renderAutoSyncScheduleModal() { return `
${_esc(p.name)}
-
${p.track_count || 0} tracks · ${_esc(assigned)}
+
${p.track_count || 0} tracks · ${_esc(assigned)}
`; }).join('')} @@ -753,29 +797,20 @@ function renderAutoSyncScheduleModal() { ${assigned.length}
- ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop playlists here
'} + ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'}
`; }).join(''); - const managedHtml = automationManaged.length ? ` -
-
Automation-managed pipelines
- ${automationManaged.map(a => `${_esc(a.name || 'Playlist Pipeline')}`).join('')} -
- ` : ''; - - overlay.innerHTML = ` -
-
-
-

Auto-Sync Schedule

-

Drag mirrored playlists into an interval. Each placement creates or updates a matching playlist-pipeline automation.

-
- + return ` +
+
+ Drag playlists into an interval + Each placement creates or updates an Auto-Sync-owned playlist-pipeline automation.
- ${managedHtml} + +
${bucketHtml}
+ `; +} + +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 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'; + return ` +
+
+
+ ${enabled ? 'Enabled' : 'Disabled'} + ${_esc(auto.name || 'Playlist Pipeline')} +
+
+ ${_esc(trigger)} + ${_esc(target)} + ${_esc(next)} +
+
+
Read only
`; } function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; - const owned = schedule?.owned === true; return `
${_esc(playlist.name)}
-
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks${schedule?.next_run ? ` · ${autoSyncNextRunLabel(schedule.next_run)}` : ''}${owned ? '' : ' · Automations page'}
+
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks${schedule?.next_run ? ` · ${autoSyncNextRunLabel(schedule.next_run)}` : ''}
- ${owned - ? `` - : 'Lock'} +
`; } @@ -839,10 +913,6 @@ async function saveAutoSyncPlaylistSchedule(playlistId, hours) { const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); if (!playlist) return; const existing = _autoSyncScheduleState.playlistSchedules[playlistId]; - if (existing && !existing.owned) { - showToast('This playlist pipeline is managed from the Automations page for now.', 'info'); - return; - } const payload = { name: `Auto-Sync: ${playlist.name}`, trigger_type: 'schedule', diff --git a/webui/static/style.css b/webui/static/style.css index 7e77f94f..097f6ff1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11196,9 +11196,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-modal { - width: min(1420px, calc(100vw - 48px)); - height: min(820px, calc(100vh - 48px)); - background: rgba(18, 20, 28, 0.98); + width: min(1500px, calc(100vw - 40px)); + height: min(860px, calc(100vh - 40px)); + background: rgba(17, 19, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; box-shadow: 0 28px 80px rgba(0, 0, 0, 0.5); @@ -11216,6 +11216,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-bottom: 1px solid rgba(255, 255, 255, 0.08); } +.auto-sync-eyebrow { + margin-bottom: 6px; + color: #7dd3fc; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + .auto-sync-header h3 { margin: 0 0 6px; color: rgba(255, 255, 255, 0.92); @@ -11246,6 +11254,116 @@ body.helper-mode-active #dashboard-activity-feed:hover { color: #fff; } +.auto-sync-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + background: rgba(255, 255, 255, 0.08); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.auto-sync-summary div { + padding: 14px 20px; + background: rgba(255, 255, 255, 0.025); +} + +.auto-sync-summary span { + display: block; + color: rgba(255, 255, 255, 0.92); + font-size: 20px; + font-weight: 800; +} + +.auto-sync-summary small { + display: block; + margin-top: 2px; + color: rgba(255, 255, 255, 0.42); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.auto-sync-tabs { + display: flex; + gap: 8px; + padding: 12px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.018); +} + +.auto-sync-tabs button { + height: 32px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.58); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.auto-sync-tabs button:hover, +.auto-sync-tabs button.active { + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + color: #e0f2fe; +} + +.auto-sync-tab-panel { + display: none; + min-height: 0; + flex: 1; +} + +.auto-sync-tab-panel.active { + display: flex; + flex-direction: column; +} + +.auto-sync-board-intro, +.auto-sync-automation-intro { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(56, 189, 248, 0.035); +} + +.auto-sync-board-intro strong, +.auto-sync-automation-intro strong { + display: block; + color: rgba(255, 255, 255, 0.86); + font-size: 13px; +} + +.auto-sync-board-intro span, +.auto-sync-automation-intro span { + display: block; + margin-top: 2px; + color: rgba(255, 255, 255, 0.46); + font-size: 12px; +} + +.auto-sync-board-intro button { + height: 30px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 12px; + font-weight: 700; +} + +.auto-sync-board-intro button:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + .auto-sync-body { min-height: 0; flex: 1; @@ -11262,7 +11380,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-sidebar-title { - padding: 16px 18px 12px; + padding: 16px 18px 10px; color: rgba(255, 255, 255, 0.72); font-size: 12px; font-weight: 700; @@ -11295,7 +11413,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-playlist { - padding: 10px; + padding: 11px 10px; margin-bottom: 8px; } @@ -11331,7 +11449,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { overflow-x: auto; padding: 18px; display: grid; - grid-template-columns: repeat(10, minmax(170px, 1fr)); + grid-template-columns: repeat(10, minmax(185px, 1fr)); gap: 12px; } @@ -11381,6 +11499,22 @@ body.helper-mode-active #dashboard-activity-feed:hover { padding: 18px 10px; } +.auto-sync-drop-hint strong, +.auto-sync-drop-hint span { + display: block; +} + +.auto-sync-drop-hint strong { + color: rgba(255, 255, 255, 0.52); + font-size: 12px; +} + +.auto-sync-drop-hint span { + margin-top: 3px; + color: rgba(255, 255, 255, 0.32); + font-size: 11px; +} + .auto-sync-loading, .auto-sync-error { padding: 48px; @@ -11414,40 +11548,103 @@ body.helper-mode-active #dashboard-activity-feed:hover { background: rgba(239, 68, 68, 0.12); } -.auto-sync-lock { - align-self: flex-start; - padding: 4px 6px; - border-radius: 5px; - background: rgba(255, 255, 255, 0.06); - color: rgba(255, 255, 255, 0.38); +.auto-sync-automation-list { + min-height: 0; + overflow-y: auto; + padding: 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.auto-sync-automation-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); +} + +.auto-sync-automation-card:hover { + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.055); +} + +.auto-sync-automation-main { + min-width: 0; + flex: 1; +} + +.auto-sync-automation-title-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.auto-sync-automation-title-row strong { + color: rgba(255, 255, 255, 0.88); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-status { + padding: 3px 7px; + border-radius: 999px; font-size: 10px; - font-weight: 700; + font-weight: 800; text-transform: uppercase; flex-shrink: 0; } -.auto-sync-managed { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 24px; - border-bottom: 1px solid rgba(255, 255, 255, 0.07); - color: rgba(255, 255, 255, 0.48); - font-size: 12px; - overflow-x: auto; +.auto-sync-status.enabled { + background: rgba(34, 197, 94, 0.14); + color: #4ade80; } -.auto-sync-managed-title { - color: rgba(255, 255, 255, 0.72); - font-weight: 700; +.auto-sync-status.disabled { + background: rgba(148, 163, 184, 0.14); + color: #94a3b8; +} + +.auto-sync-automation-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.auto-sync-automation-meta span { + padding: 4px 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.48); + font-size: 11px; +} + +.auto-sync-automation-lock { + padding: 6px 9px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.42); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; flex-shrink: 0; } -.auto-sync-managed span { - padding: 4px 8px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); - white-space: nowrap; +.auto-sync-automation-empty { + margin: 24px; + padding: 44px; + border: 1px dashed rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.42); + text-align: center; } /* Enhanced Progress Bar Animation */