From d35b09fc3c426bbd99236a26300fc2bac55eac8c Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 6 Jun 2026 16:32:10 -0700 Subject: [PATCH] Auto-Sync tile: light for the WHOLE pipeline, including scheduled auto-sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tile's liveness was wired to sync:progress / discovery:progress — both ROOM-scoped (only clients watching a specific playlist receive them), so the dashboard tile would basically never light. And the scheduled auto-sync runs as an automation, reporting on automation:progress — the wrong tile. The 1s sync emitter now also sends an UNSCOPED sync:active heartbeat while any playlist work is running anywhere: manual per-playlist syncs (sync_states), the UI-triggered mirrored pipeline (playlist_pipeline_progress_states), and scheduled auto-sync pipelines (running automations whose action_type is playlist_pipeline / sync_playlist / refresh_mirrored). Emitted only while active; the tile's 6s freshness decay handles the off. The dashboard listens for the heartbeat alongside the (kept) room-scoped signals. --- web_server.py | 29 +++++++++++++++++++++++++++++ webui/static/core.js | 5 +++++ 2 files changed, 34 insertions(+) diff --git a/web_server.py b/web_server.py index ebb88424..962f0129 100644 --- a/web_server.py +++ b/web_server.py @@ -35269,6 +35269,27 @@ def handle_discovery_unsubscribe(data): for pid in data.get('ids', []): leave_room(f'discovery:{pid}') +_SYNC_ACTIVE_STATUSES = ('starting', 'syncing', 'running', 'in_progress', 'discovering', 'analyzing') +_SYNC_AUTOMATION_TYPES = ('playlist_pipeline', 'sync_playlist', 'refresh_mirrored') + + +def _any_playlist_sync_running() -> bool: + """True while ANY playlist sync work is running anywhere: a manual + per-playlist sync, the UI-triggered mirrored pipeline, or a scheduled + auto-sync pipeline (which runs as a playlist-flavored automation).""" + with sync_lock: + if any((s or {}).get('status') in _SYNC_ACTIVE_STATUSES for s in sync_states.values()): + return True + with playlist_pipeline_progress_lock: + if any((s or {}).get('status') == 'running' for s in playlist_pipeline_progress_states.values()): + return True + with _auto_progress.progress_lock: + return any( + s.get('status') == 'running' and s.get('action_type') in _SYNC_AUTOMATION_TYPES + for s in _auto_progress.progress_states.values() + ) + + def _emit_sync_progress_loop(): """Push sync progress to subscribed rooms every 1 second.""" while not globals().get('IS_SHUTTING_DOWN', False): @@ -35282,6 +35303,14 @@ def _emit_sync_progress_loop(): }, room=f'sync:{pid}') except Exception as e: logger.debug("sync progress emit failed: %s", e) + + # Quick Actions gauge heartbeat — UNSCOPED, unlike sync:progress + # which only reaches clients subscribed to a playlist room. The + # dashboard's Auto-Sync tile needs to light for ALL pipeline + # work, including the scheduled auto-sync (an automation). + # Emitted only while active; the frontend decays on silence. + if _any_playlist_sync_running(): + socketio.emit('sync:active', {'active': True}) except Exception as e: logger.debug(f"Error in sync progress loop: {e}") diff --git a/webui/static/core.js b/webui/static/core.js index d281e6cc..ca175627 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -496,6 +496,11 @@ function initializeWebSocket() { // Phase 5 event listeners (sync/discovery progress + scans) socket.on('sync:progress', (data) => { qaSignal('sync'); updateSyncProgressFromData(data); }); socket.on('discovery:progress', (data) => { qaSignal('sync'); updateDiscoveryProgressFromData(data); }); + // Unscoped heartbeat for the Auto-Sync tile: sync:progress above is + // room-scoped (only playlist watchers receive it), so the dashboard + // relies on this 1s pulse that fires while ANY pipeline work runs — + // manual syncs, UI pipelines, and the scheduled auto-sync automation. + socket.on('sync:active', () => qaSignal('sync')); socket.on('scan:watchlist', (data) => { updateWatchlistScanFromData(data); const watchlistBtn = document.querySelector('.nav-button[data-page="watchlist"]');