From efdcde1892cc6f1dc97e0406c6c5b69c17fd3801 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 00:55:55 -0700 Subject: [PATCH] Add playlist auto-sync run history Persist per-playlist pipeline run snapshots from the shared playlist pipeline, expose a history API, and upgrade the Auto-Sync modal with live pipeline monitoring, Run now controls, and a runs-style history tab. --- core/playlists/pipeline.py | 118 ++++++++- database/music_database.py | 91 +++++++ web_server.py | 31 +++ webui/static/auto-sync.js | 286 ++++++++++++++++++++- webui/static/style.css | 510 +++++++++++++++++++++++++++++++++++-- 5 files changed, 999 insertions(+), 37 deletions(-) diff --git a/core/playlists/pipeline.py b/core/playlists/pipeline.py index 2bf0fc9c..00d76432 100644 --- a/core/playlists/pipeline.py +++ b/core/playlists/pipeline.py @@ -11,8 +11,10 @@ future playlist-card "Run Pipeline" button can call the same command. from __future__ import annotations +import json import threading import time +from datetime import datetime, timezone from typing import Any, Callable, Dict, List @@ -48,7 +50,12 @@ def run_mirrored_playlist_pipeline( else: deps.state.set_pipeline_running(True) automation_id = config.get('_automation_id') + trigger_source = config.get('_trigger_source') or ( + 'manual' if str(automation_id or '').startswith('mirrored_') else 'automation' + ) pipeline_start = time.time() + history_playlists: List[Dict[str, Any]] = [] + before_snapshots: Dict[int, Dict[str, Any]] = {} try: db = deps.get_database() @@ -65,6 +72,12 @@ def run_mirrored_playlist_pipeline( if not playlists: deps.state.set_pipeline_running(False) return {'status': 'error', 'error': 'No refreshable playlists found'} + history_playlists = list(playlists) + before_snapshots = { + int(pl['id']): _playlist_history_snapshot(db, pl) + for pl in history_playlists + if pl.get('id') + } deps.update_progress( automation_id, @@ -117,8 +130,7 @@ def run_mirrored_playlist_pipeline( log_type='success', ) - deps.state.set_pipeline_running(False) - return { + result = { 'status': 'completed', '_manages_own_progress': True, 'playlists_refreshed': str(refreshed), @@ -128,9 +140,38 @@ def run_mirrored_playlist_pipeline( 'wishlist_queued': str(sync_summary['wishlist_queued']), 'duration_seconds': str(duration), } + try: + _record_playlist_pipeline_history( + db, + history_playlists, + before_snapshots, + result, + status='completed', + started_at=pipeline_start, + finished_at=time.time(), + trigger_source=trigger_source, + ) + except Exception as history_error: # noqa: BLE001 - history should never fail a successful pipeline + deps.logger.debug(f"[Pipeline] History recording failed: {history_error}") + deps.state.set_pipeline_running(False) + return result except Exception as e: # noqa: BLE001 - pipeline callers should receive status dicts deps.state.set_pipeline_running(False) + try: + if history_playlists: + _record_playlist_pipeline_history( + db, + history_playlists, + before_snapshots, + {'status': 'error', 'error': str(e), '_manages_own_progress': True}, + status='error', + started_at=pipeline_start, + finished_at=time.time(), + trigger_source=trigger_source, + ) + except Exception as history_error: # noqa: BLE001 - history should never mask pipeline errors + deps.logger.debug(f"[Pipeline] History recording failed after error: {history_error}") deps.update_progress( automation_id, status='error', @@ -142,6 +183,79 @@ def run_mirrored_playlist_pipeline( return {'status': 'error', 'error': str(e), '_manages_own_progress': True} +def _pipeline_history_timestamp(ts: float) -> str: + return datetime.fromtimestamp(ts, timezone.utc).isoformat() + + +def _playlist_history_snapshot(db: Any, playlist: Dict[str, Any]) -> Dict[str, Any]: + playlist_id = int(playlist['id']) + current = db.get_mirrored_playlist(playlist_id) or playlist + counts = db.get_mirrored_playlist_status_counts(playlist_id) + return { + 'playlist_id': playlist_id, + 'name': current.get('name') or playlist.get('name') or '', + 'source': current.get('source') or playlist.get('source') or '', + 'track_count': int(counts.get('total') or current.get('track_count') or 0), + 'discovered_count': int(counts.get('discovered') or 0), + 'wishlisted_count': int(counts.get('wishlisted') or 0), + 'in_library_count': int(counts.get('in_library') or 0), + } + + +def _playlist_history_summary(before: Dict[str, Any], after: Dict[str, Any], status: str) -> str: + before_tracks = int(before.get('track_count') or 0) + after_tracks = int(after.get('track_count') or 0) + track_delta = after_tracks - before_tracks + before_discovered = int(before.get('discovered_count') or 0) + after_discovered = int(after.get('discovered_count') or 0) + discovered_delta = after_discovered - before_discovered + parts = [status.capitalize()] + parts.append(f"{before_tracks} -> {after_tracks} tracks") + if track_delta: + parts.append(f"{track_delta:+d} tracks") + if discovered_delta: + parts.append(f"{discovered_delta:+d} discovered") + return ' | '.join(parts) + + +def _record_playlist_pipeline_history( + db: Any, + playlists: List[Dict[str, Any]], + before_snapshots: Dict[int, Dict[str, Any]], + result: Dict[str, Any], + *, + status: str, + started_at: float, + finished_at: float, + trigger_source: str, +) -> None: + if not hasattr(db, 'insert_playlist_pipeline_run_history'): + return + duration = max(0, finished_at - started_at) + for playlist in playlists: + if not playlist.get('id'): + continue + playlist_id = int(playlist['id']) + before = before_snapshots.get(playlist_id, {}) + after = _playlist_history_snapshot(db, playlist) + db.insert_playlist_pipeline_run_history( + playlist_id=playlist_id, + playlist_name=after.get('name') or playlist.get('name') or '', + source=after.get('source') or playlist.get('source') or '', + profile_id=int(playlist.get('profile_id') or 1), + trigger_source=trigger_source, + started_at=_pipeline_history_timestamp(started_at), + finished_at=_pipeline_history_timestamp(finished_at), + duration_seconds=duration, + status=status, + summary=_playlist_history_summary(before, after, status), + before_json=json.dumps(before), + after_json=json.dumps(after), + result_json=json.dumps(result), + log_lines=None, + ) + + def _resolve_pipeline_playlists(db: Any, playlist_id: Any, process_all: bool) -> List[Dict[str, Any]] | None: if process_all: return db.get_mirrored_playlists() diff --git a/database/music_database.py b/database/music_database.py index 6ffd1311..e27500e9 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -531,6 +531,30 @@ class MusicDatabase: """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_arh_automation_id ON automation_run_history(automation_id)") + # Playlist pipeline run history table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS playlist_pipeline_run_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id INTEGER, + playlist_name TEXT, + source TEXT, + profile_id INTEGER DEFAULT 1, + trigger_source TEXT DEFAULT 'pipeline', + started_at TIMESTAMP, + finished_at TIMESTAMP, + duration_seconds REAL, + status TEXT NOT NULL, + summary TEXT, + before_json TEXT, + after_json TEXT, + result_json TEXT, + log_lines TEXT, + FOREIGN KEY (playlist_id) REFERENCES mirrored_playlists(id) ON DELETE SET NULL + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_playlist_id ON playlist_pipeline_run_history(playlist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_profile_id ON playlist_pipeline_run_history(profile_id)") + # Add explored_at to mirrored_playlists (migration) self._add_mirrored_playlist_explored_column(cursor) @@ -12417,6 +12441,73 @@ class MusicDatabase: logger.error(f"Error clearing automation run history: {e}") return 0 + def insert_playlist_pipeline_run_history(self, playlist_id, playlist_name, source, + profile_id, trigger_source, started_at, + finished_at, duration_seconds, status, + summary=None, before_json=None, + after_json=None, result_json=None, + log_lines=None): + """Insert a playlist pipeline run history entry and retain recent rows per profile.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO playlist_pipeline_run_history + (playlist_id, playlist_name, source, profile_id, trigger_source, + started_at, finished_at, duration_seconds, status, summary, + before_json, after_json, result_json, log_lines) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + playlist_id, playlist_name, source, profile_id, trigger_source, + started_at, finished_at, duration_seconds, status, summary, + before_json, after_json, result_json, log_lines, + )) + cursor.execute(""" + DELETE FROM playlist_pipeline_run_history + WHERE profile_id = ? AND id NOT IN ( + SELECT id FROM playlist_pipeline_run_history + WHERE profile_id = ? + ORDER BY id DESC LIMIT 300 + ) + """, (profile_id, profile_id)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error inserting playlist pipeline run history for {playlist_id}: {e}") + return False + + def get_playlist_pipeline_run_history(self, profile_id=1, playlist_id=None, limit=50, offset=0): + """Get playlist pipeline run history, newest first.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + where = ["profile_id = ?"] + params = [profile_id] + if playlist_id: + where.append("playlist_id = ?") + params.append(playlist_id) + where_sql = " AND ".join(where) + cursor.execute( + f"SELECT COUNT(*) FROM playlist_pipeline_run_history WHERE {where_sql}", + params, + ) + total = cursor.fetchone()[0] + cursor.execute(f""" + SELECT id, playlist_id, playlist_name, source, profile_id, trigger_source, + started_at, finished_at, duration_seconds, status, summary, + before_json, after_json, result_json, log_lines + FROM playlist_pipeline_run_history + WHERE {where_sql} + ORDER BY id DESC + LIMIT ? OFFSET ? + """, [*params, limit, offset]) + cols = [d[0] for d in cursor.description] + rows = [dict(zip(cols, row, strict=False)) for row in cursor.fetchall()] + return {'history': rows, 'total': total} + except Exception as e: + logger.error(f"Error getting playlist pipeline run history: {e}") + return {'history': [], 'total': 0} + def get_radio_tracks(self, track_id, limit=20, exclude_ids=None) -> Dict[str, Any]: """Find similar tracks for radio mode auto-play queue. diff --git a/web_server.py b/web_server.py index 51303934..f50a9e18 100644 --- a/web_server.py +++ b/web_server.py @@ -32479,6 +32479,37 @@ def get_mirrored_playlist_pipeline_status_endpoint(playlist_id): logger.error(f"Error getting mirrored playlist pipeline status: {e}") return jsonify({"error": str(e)}), 500 + +@app.route('/api/playlist-pipeline/history', methods=['GET']) +def get_playlist_pipeline_history_endpoint(): + """Return persisted run history for mirrored playlist pipeline executions.""" + try: + database = get_database() + profile_id = get_current_profile_id() + limit = min(max(int(request.args.get('limit', 50)), 1), 100) + offset = max(int(request.args.get('offset', 0)), 0) + playlist_id_raw = request.args.get('playlist_id') + playlist_id = int(playlist_id_raw) if playlist_id_raw else None + data = database.get_playlist_pipeline_run_history( + profile_id=profile_id, + playlist_id=playlist_id, + limit=limit, + offset=offset, + ) + for entry in data.get('history', []): + for key in ('before_json', 'after_json', 'result_json', 'log_lines'): + if entry.get(key): + try: + entry[key] = json.loads(entry[key]) + except (json.JSONDecodeError, TypeError): + entry[key] = [] if key == 'log_lines' else {} + else: + entry[key] = [] if key == 'log_lines' else {} + return jsonify(data) + except Exception as e: + logger.error(f"Error getting playlist pipeline history: {e}") + return jsonify({"error": str(e)}), 500 + @app.route('/api/mirrored-playlists/', methods=['DELETE']) def delete_mirrored_playlist_endpoint(playlist_id): """Delete a mirrored playlist.""" diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index e30cda02..b54b725e 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -10,11 +10,15 @@ 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'; @@ -103,7 +107,7 @@ function autoSyncIsScheduleOwned(auto) { return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:'); } -function buildAutoSyncScheduleState(playlists, automations) { +function buildAutoSyncScheduleState(playlists, automations, historyData = {}) { const playlistSchedules = {}; const automationPipelines = []; const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation); @@ -124,7 +128,14 @@ function buildAutoSyncScheduleState(playlists, automations) { automationPipelines.push(auto); } }); - return { playlists, automations, playlistSchedules, automationPipelines }; + return { + playlists, + automations, + playlistSchedules, + automationPipelines, + runHistory: historyData.history || [], + runHistoryTotal: historyData.total || 0, + }; } async function openAutoSyncScheduleModal() { @@ -154,6 +165,7 @@ async function openAutoSyncScheduleModal() { function closeAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); + stopAutoSyncStatusPolling(); if (overlay) overlay.remove(); } @@ -161,16 +173,20 @@ async function refreshAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; try { - const [playlistRes, automationRes] = await Promise.all([ + 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'); - _autoSyncScheduleState = buildAutoSyncScheduleState(playlists, 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 = `
@@ -188,16 +204,19 @@ function renderAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; - const { playlists, playlistSchedules, automationPipelines } = _autoSyncScheduleState; + 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 = `
@@ -215,18 +234,21 @@ function renderAutoSyncScheduleModal() {
${pipelineCount}automation pipelines
${totalTracks}mirrored tracks
+ ${monitor}
+
${schedulePanel}
${automationPanel}
+
${historyPanel}
`; } function setAutoSyncTab(tab) { - _autoSyncActiveTab = tab === 'automations' ? 'automations' : 'schedule'; + _autoSyncActiveTab = ['automations', 'history'].includes(tab) ? tab : 'schedule'; renderAutoSyncScheduleModal(); } @@ -248,7 +270,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { const schedule = playlistSchedules[p.id]; const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; return ` -
+
${_esc(p.name)}
${p.track_count || 0} tracks · ${_esc(assigned)}
@@ -302,6 +324,90 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { `; } +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('') : ` +
+ Ready + Scheduled playlists will 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.
'; @@ -317,6 +423,118 @@ function renderAutoSyncAutomationPanel(automationPipelines, playlists) { `; } +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(autoSyncHistoryEntryHtml).join('')} + ${total > history.length ? `
Showing ${history.length} of ${total} runs
` : ''} +
+ `; +} + +function autoSyncHistoryEntryHtml(entry) { + 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}`; + return ` +
+
+ ${_esc(autoSyncHistoryStatusLabel(status))} +
+ ${_esc(entry.playlist_name || 'Mirrored playlist')} + ${_esc(entry.summary || '')} +
+
+ ${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)} +
+
+ ${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)}` : ''} +
+
+
+ `; +} + +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 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 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); @@ -348,8 +566,9 @@ function autoSyncAutomationCardHtml(auto, playlists) { 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
@@ -358,7 +577,10 @@ function autoSyncScheduledCardHtml(playlist, schedule) { ${nextLabel ? `${_esc(nextLabel)}` : ''}
- +
+ + +
`; } @@ -379,6 +601,7 @@ function autoSyncNextRunLabel(nextRun) { function autoSyncDragStart(event) { const playlistId = event.currentTarget?.dataset?.playlistId; if (!playlistId) return; + _autoSyncIsDragging = true; event.dataTransfer.setData('text/plain', playlistId); event.dataTransfer.effectAllowed = 'move'; } @@ -401,6 +624,7 @@ function autoSyncDragLeave(event) { 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); @@ -408,6 +632,10 @@ async function autoSyncDrop(event, hours) { 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; @@ -457,6 +685,37 @@ async function unscheduleAutoSyncPlaylist(playlistId) { } } +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 = {}; @@ -543,6 +802,13 @@ async function runMirroredPlaylistPipeline(playlistId, name) { 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'); @@ -564,11 +830,13 @@ function pollMirroredPipelineStatus(playlistId, name) { 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]; diff --git a/webui/static/style.css b/webui/static/style.css index df0752c8..1dad85bc 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11203,7 +11203,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-modal { width: min(1500px, calc(100vw - 40px)); - height: min(860px, calc(100vh - 40px)); + height: min(920px, calc(100vh - 32px)); background: radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08) 0%, transparent 60%), rgba(17, 19, 27, 0.98); @@ -11296,6 +11296,214 @@ body.helper-mode-active #dashboard-activity-feed:hover { text-transform: uppercase; } +.auto-sync-monitor { + flex-shrink: 0; + padding: 14px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: + radial-gradient(circle at 14% 0%, rgba(var(--accent-rgb), 0.14), transparent 34%), + rgba(255, 255, 255, 0.018); +} + +.auto-sync-monitor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 10px; +} + +.auto-sync-monitor-head strong, +.auto-sync-monitor-head small, +.auto-sync-monitor-kicker { + display: block; +} + +.auto-sync-monitor-kicker { + margin-bottom: 3px; + color: rgb(var(--accent-light-rgb)); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.auto-sync-monitor-head strong { + color: rgba(255, 255, 255, 0.9); + font-size: 14px; +} + +.auto-sync-monitor-head small { + margin-top: 2px; + color: rgba(255, 255, 255, 0.44); + font-size: 12px; +} + +.auto-sync-monitor-head button, +.auto-sync-board-intro button, +.auto-sync-history-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-monitor-head button:hover, +.auto-sync-board-intro button:hover, +.auto-sync-history-intro button:hover { + background: rgba(var(--accent-rgb), 0.14); + border-color: rgba(var(--accent-rgb), 0.32); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-monitor-list { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.auto-sync-monitor-card, +.auto-sync-monitor-empty { + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); +} + +.auto-sync-monitor-card { + display: flex; + align-items: stretch; + justify-content: space-between; + gap: 10px; + padding: 11px; +} + +.auto-sync-monitor-card.running { + border-color: rgba(var(--accent-rgb), 0.34); + background: rgba(var(--accent-rgb), 0.08); + box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.08), 0 8px 24px rgba(var(--accent-rgb), 0.08); +} + +.auto-sync-monitor-card.error { + border-color: rgba(239, 68, 68, 0.28); + background: rgba(239, 68, 68, 0.07); +} + +.auto-sync-monitor-card.finished { + border-color: rgba(34, 197, 94, 0.22); + background: rgba(34, 197, 94, 0.055); +} + +.auto-sync-monitor-card-main { + min-width: 0; + flex: 1; +} + +.auto-sync-monitor-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.auto-sync-monitor-title-row strong { + min-width: 0; + color: rgba(255, 255, 255, 0.88); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-monitor-title-row span { + flex-shrink: 0; + color: rgb(var(--accent-light-rgb)); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.auto-sync-monitor-phase { + margin-top: 5px; + color: rgba(255, 255, 255, 0.52); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-monitor-progress { + height: 5px; + margin-top: 8px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.auto-sync-monitor-progress div { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb))); + transition: width 0.25s ease; +} + +.auto-sync-monitor-card small { + display: block; + margin-top: 6px; + color: rgba(255, 255, 255, 0.38); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-monitor-card button { + align-self: center; + height: 28px; + padding: 0 9px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.64); + cursor: pointer; + font-size: 11px; + font-weight: 800; +} + +.auto-sync-monitor-card button:hover { + border-color: rgba(var(--accent-rgb), 0.36); + background: rgba(var(--accent-rgb), 0.14); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-monitor-empty { + grid-column: 1 / -1; + padding: 16px; +} + +.auto-sync-monitor-empty span, +.auto-sync-monitor-empty small { + display: block; +} + +.auto-sync-monitor-empty span { + color: rgba(255, 255, 255, 0.72); + font-size: 13px; + font-weight: 800; +} + +.auto-sync-monitor-empty small { + margin-top: 3px; + color: rgba(255, 255, 255, 0.38); + font-size: 12px; +} + .auto-sync-tabs { display: flex; gap: 8px; @@ -11337,7 +11545,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-board-intro, -.auto-sync-automation-intro { +.auto-sync-automation-intro, +.auto-sync-history-intro { display: flex; align-items: center; justify-content: space-between; @@ -11349,37 +11558,22 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .auto-sync-board-intro strong, -.auto-sync-automation-intro strong { +.auto-sync-automation-intro strong, +.auto-sync-history-intro strong { display: block; color: rgba(255, 255, 255, 0.86); font-size: 13px; } .auto-sync-board-intro span, -.auto-sync-automation-intro span { +.auto-sync-automation-intro span, +.auto-sync-history-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; @@ -11561,7 +11755,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-column-list::-webkit-scrollbar, .auto-sync-board::-webkit-scrollbar, .auto-sync-source-list::-webkit-scrollbar, -.auto-sync-automation-list::-webkit-scrollbar { +.auto-sync-automation-list::-webkit-scrollbar, +.auto-sync-history-list::-webkit-scrollbar { width: 6px; height: 6px; } @@ -11569,7 +11764,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-column-list::-webkit-scrollbar-thumb, .auto-sync-board::-webkit-scrollbar-thumb, .auto-sync-source-list::-webkit-scrollbar-thumb, -.auto-sync-automation-list::-webkit-scrollbar-thumb { +.auto-sync-automation-list::-webkit-scrollbar-thumb, +.auto-sync-history-list::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb), 0.3); border-radius: 999px; } @@ -11577,7 +11773,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .auto-sync-column-list::-webkit-scrollbar-thumb:hover, .auto-sync-board::-webkit-scrollbar-thumb:hover, .auto-sync-source-list::-webkit-scrollbar-thumb:hover, -.auto-sync-automation-list::-webkit-scrollbar-thumb:hover { +.auto-sync-automation-list::-webkit-scrollbar-thumb:hover, +.auto-sync-history-list::-webkit-scrollbar-thumb:hover { background: rgba(var(--accent-rgb), 0.5); } @@ -11629,6 +11826,14 @@ body.helper-mode-active #dashboard-activity-feed:hover { opacity: 0.52; } +.auto-sync-scheduled-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + flex-shrink: 0; +} + .auto-sync-scheduled-card button { width: 24px; height: 24px; @@ -11640,7 +11845,29 @@ body.helper-mode-active #dashboard-activity-feed:hover { flex-shrink: 0; } -.auto-sync-scheduled-card button:hover { +.auto-sync-scheduled-card button.run { + width: auto; + min-width: 64px; + padding: 0 8px; + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.12); + border: 1px solid rgba(var(--accent-rgb), 0.22); + font-size: 10px; + font-weight: 800; +} + +.auto-sync-scheduled-card button.run:disabled { + cursor: default; + opacity: 0.62; +} + +.auto-sync-scheduled-card button.run:not(:disabled):hover { + color: rgb(var(--accent-neon-rgb)); + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.4); +} + +.auto-sync-scheduled-card button:not(.run):hover { color: #ef4444; background: rgba(239, 68, 68, 0.12); } @@ -11744,6 +11971,191 @@ body.helper-mode-active #dashboard-activity-feed:hover { text-align: center; } +.auto-sync-history-list { + min-height: 0; + overflow-y: auto; + padding: 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.auto-sync-history-entry { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.035); + overflow: hidden; +} + +.auto-sync-history-entry:hover { + border-color: rgba(var(--accent-rgb), 0.22); +} + +.auto-sync-history-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 13px 14px; + cursor: pointer; +} + +.auto-sync-history-status { + padding: 4px 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.14); + color: #94a3b8; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.auto-sync-history-status.completed, +.auto-sync-history-status.finished { + background: rgba(74, 222, 128, 0.14); + color: #4ade80; +} + +.auto-sync-history-status.error { + background: rgba(239, 68, 68, 0.14); + color: #ef4444; +} + +.auto-sync-history-status.skipped { + background: rgba(250, 204, 21, 0.14); + color: #facc15; +} + +.auto-sync-history-main { + min-width: 0; +} + +.auto-sync-history-main strong, +.auto-sync-history-main small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auto-sync-history-main strong { + color: rgba(255, 255, 255, 0.88); + font-size: 13px; +} + +.auto-sync-history-main small { + margin-top: 3px; + color: rgba(255, 255, 255, 0.42); + font-size: 11px; +} + +.auto-sync-history-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.auto-sync-history-meta span, +.auto-sync-history-result span { + padding: 4px 7px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.48); + font-size: 10px; + font-weight: 700; +} + +.auto-sync-history-detail { + display: none; + padding: 0 14px 14px; +} + +.auto-sync-history-detail.expanded { + display: block; +} + +.auto-sync-history-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + padding-top: 2px; +} + +.auto-sync-history-stats div { + padding: 10px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); +} + +.auto-sync-history-stats span, +.auto-sync-history-stats strong { + display: block; +} + +.auto-sync-history-stats span { + color: rgba(255, 255, 255, 0.38); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.auto-sync-history-stats strong { + margin-top: 4px; + color: rgba(255, 255, 255, 0.82); + font-size: 12px; +} + +.auto-sync-history-result { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 9px; +} + +.auto-sync-history-result span { + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.18); +} + +.auto-sync-history-result span.error { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.18); +} + +.auto-sync-history-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; +} + +.auto-sync-history-empty strong, +.auto-sync-history-empty span { + display: block; +} + +.auto-sync-history-empty strong { + color: rgba(255, 255, 255, 0.72); + font-size: 14px; +} + +.auto-sync-history-empty span { + margin-top: 6px; + font-size: 12px; +} + +.auto-sync-history-total { + padding: 12px; + color: rgba(255, 255, 255, 0.38); + font-size: 12px; + text-align: center; +} + @media (max-width: 1100px) { .auto-sync-modal { width: calc(100vw - 18px); @@ -11754,6 +12166,10 @@ body.helper-mode-active #dashboard-activity-feed:hover { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .auto-sync-monitor-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .auto-sync-body { grid-template-columns: 1fr; grid-template-rows: minmax(150px, 30%) 1fr; @@ -11795,16 +12211,58 @@ body.helper-mode-active #dashboard-activity-feed:hover { padding: 9px 16px; } + .auto-sync-monitor { + padding: 10px 14px; + } + + .auto-sync-monitor-empty, + .auto-sync-monitor-card { + padding: 9px; + } + .auto-sync-tabs { padding: 9px 14px; } .auto-sync-board-intro, - .auto-sync-automation-intro { + .auto-sync-automation-intro, + .auto-sync-history-intro { padding: 9px 14px; } } +@media (max-width: 720px) { + .auto-sync-monitor-list { + grid-template-columns: 1fr; + } + + .auto-sync-monitor-head { + align-items: flex-start; + flex-direction: column; + } + + .auto-sync-monitor-card { + flex-direction: column; + } + + .auto-sync-monitor-card button { + align-self: flex-start; + } + + .auto-sync-history-row { + grid-template-columns: 1fr; + align-items: flex-start; + } + + .auto-sync-history-meta { + justify-content: flex-start; + } + + .auto-sync-history-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + /* Enhanced Progress Bar Animation */ .progress-bar-fill { height: 100%;