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.
This commit is contained in:
Broque Thomas 2026-05-25 00:55:55 -07:00
parent 5f62152acb
commit efdcde1892
5 changed files with 999 additions and 37 deletions

View file

@ -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()

View file

@ -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.

View file

@ -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/<int:playlist_id>', methods=['DELETE'])
def delete_mirrored_playlist_endpoint(playlist_id):
"""Delete a mirrored playlist."""

View file

@ -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 = `
<div class="auto-sync-modal">
@ -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 = `
<div class="auto-sync-modal">
@ -215,18 +234,21 @@ function renderAutoSyncScheduleModal() {
<div><span>${pipelineCount}</span><small>automation pipelines</small></div>
<div><span>${totalTracks}</span><small>mirrored tracks</small></div>
</div>
${monitor}
<div class="auto-sync-tabs">
<button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Schedule Board</button>
<button class="${automationsActive ? 'active' : ''}" onclick="setAutoSyncTab('automations')">Automation Pipelines</button>
<button class="${historyActive ? 'active' : ''}" onclick="setAutoSyncTab('history')">Run History</button>
</div>
<div class="auto-sync-tab-panel ${scheduleActive ? 'active' : ''}" id="auto-sync-schedule-panel">${schedulePanel}</div>
<div class="auto-sync-tab-panel ${automationsActive ? 'active' : ''}" id="auto-sync-automation-panel">${automationPanel}</div>
<div class="auto-sync-tab-panel ${historyActive ? 'active' : ''}" id="auto-sync-history-panel">${historyPanel}</div>
</div>
`;
}
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 `
<div class="auto-sync-playlist ${schedule ? 'scheduled' : ''}" draggable="true" data-playlist-id="${p.id}" ondragstart="autoSyncDragStart(event)">
<div class="auto-sync-playlist ${schedule ? 'scheduled' : ''}" draggable="true" data-playlist-id="${p.id}" ondragstart="autoSyncDragStart(event)" ondragend="autoSyncDragEnd()">
<div class="auto-sync-playlist-name">${_esc(p.name)}</div>
<div class="auto-sync-playlist-meta">${p.track_count || 0} tracks &middot; ${_esc(assigned)}</div>
</div>
@ -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 `
<section class="auto-sync-monitor">
<div class="auto-sync-monitor-head">
<div>
<span class="auto-sync-monitor-kicker">Live pipeline monitor</span>
<strong>${_esc(title)}</strong>
<small>${_esc(detail)}</small>
</div>
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
</div>
<div class="auto-sync-monitor-list">
${visible.length ? visible.map(({ playlist, state }) => autoSyncPipelineMonitorCardHtml(playlist, state)).join('') : `
<div class="auto-sync-monitor-empty">
<span>Ready</span>
<small>Scheduled playlists will appear here while the all-in-one pipeline runs.</small>
</div>
`}
</div>
</section>
`;
}
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 `
<article class="auto-sync-monitor-card ${autoSyncPipelineStatusClass(status)}">
<div class="auto-sync-monitor-card-main">
<div class="auto-sync-monitor-title-row">
<strong>${_esc(playlist.name || `Playlist #${playlist.id}`)}</strong>
<span>${_esc(autoSyncPipelineStatusLabel(status))}</span>
</div>
<div class="auto-sync-monitor-phase">${_esc(phase)}</div>
<div class="auto-sync-monitor-progress" aria-label="${progress}% complete">
<div style="width: ${progress}%"></div>
</div>
${latest ? `<small>${_esc(latest)}</small>` : ''}
</div>
<button onclick="event.stopPropagation(); openMirroredPlaylistModal(${playlist.id})">Details</button>
</article>
`;
}
function renderAutoSyncAutomationPanel(automationPipelines, playlists) {
if (!automationPipelines.length) {
return '<div class="auto-sync-automation-empty">No Automations-page playlist pipelines found.</div>';
@ -317,6 +423,118 @@ function renderAutoSyncAutomationPanel(automationPipelines, playlists) {
`;
}
function renderAutoSyncHistoryPanel(history, total) {
if (!history.length) {
return `
<div class="auto-sync-history-empty">
<strong>No playlist pipeline runs yet</strong>
<span>Future Auto-Sync and playlist pipeline runs will record before/after playlist snapshots here.</span>
</div>
`;
}
return `
<div class="auto-sync-history-intro">
<div>
<strong>Playlist pipeline run history</strong>
<span>Each run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing.</span>
</div>
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
</div>
<div class="auto-sync-history-list">
${history.map(autoSyncHistoryEntryHtml).join('')}
${total > history.length ? `<div class="auto-sync-history-total">Showing ${history.length} of ${total} runs</div>` : ''}
</div>
`;
}
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 `
<article class="auto-sync-history-entry">
<div class="auto-sync-history-row" onclick="autoSyncToggleHistoryEntry('${entryId}')">
<span class="auto-sync-history-status ${_escAttr(status)}">${_esc(autoSyncHistoryStatusLabel(status))}</span>
<div class="auto-sync-history-main">
<strong>${_esc(entry.playlist_name || 'Mirrored playlist')}</strong>
<small>${_esc(entry.summary || '')}</small>
</div>
<div class="auto-sync-history-meta">
${started ? `<span>${_esc(started)}</span>` : ''}
${duration ? `<span>${_esc(duration)}</span>` : ''}
<span>${_esc(entry.trigger_source || 'pipeline')}</span>
</div>
</div>
<div id="${entryId}" class="auto-sync-history-detail">
<div class="auto-sync-history-stats">
${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)}
</div>
<div class="auto-sync-history-result">
${autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed)}
${autoSyncHistoryResultPill('Synced', result.tracks_synced)}
${autoSyncHistoryResultPill('Skipped', result.sync_skipped)}
${autoSyncHistoryResultPill('Queued', result.wishlist_queued)}
${result.error ? `<span class="error">${_esc(result.error)}</span>` : ''}
</div>
</div>
</article>
`;
}
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 `
<div>
<span>${_esc(label)}</span>
<strong>${beforeValue} -> ${afterValue}${_esc(deltaText)}</strong>
</div>
`;
}
function autoSyncHistoryResultPill(label, value) {
if (value === undefined || value === null || value === '') return '';
return `<span>${_esc(label)}: ${_esc(String(value))}</span>`;
}
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 `
<div class="auto-sync-scheduled-card ${enabled ? '' : 'disabled'}" draggable="true" data-playlist-id="${playlist.id}" ondragstart="autoSyncDragStart(event)">
<div class="auto-sync-scheduled-card ${enabled ? '' : 'disabled'}" draggable="true" data-playlist-id="${playlist.id}" ondragstart="autoSyncDragStart(event)" ondragend="autoSyncDragEnd()">
<div>
<div class="auto-sync-scheduled-name">${_esc(playlist.name)}</div>
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} &middot; ${playlist.track_count || 0} tracks</div>
@ -358,7 +577,10 @@ function autoSyncScheduledCardHtml(playlist, schedule) {
${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''}
</div>
</div>
<button onclick="event.stopPropagation(); unscheduleAutoSyncPlaylist(${playlist.id})" title="Remove this Auto-Sync schedule">&times;</button>
<div class="auto-sync-scheduled-actions">
<button class="run" onclick="event.stopPropagation(); runAutoSyncScheduledPlaylist(${playlist.id})" title="Run the playlist pipeline now" ${isRunning ? 'disabled' : ''}>${isRunning ? 'Running' : 'Run now'}</button>
<button onclick="event.stopPropagation(); unscheduleAutoSyncPlaylist(${playlist.id})" title="Remove this Auto-Sync schedule">&times;</button>
</div>
</div>
`;
}
@ -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];

View file

@ -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%;