From e669e80a91d6116abcac062790217ae06773427c Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 17 May 2026 23:13:46 +0200 Subject: [PATCH] feat(web): header pill showing PhotoPrism indexer status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribes to PhotoPrism's /api/v1/ws channel on login and surfaces index.indexing / index.updating / index.completed events as a small status pill in the header (next to the AnimatedMule wordmark). - Shows "Indexing" + the current filename (basename, monospace) during the scan pass, "Finalizing — " during faces/counts/folders/ purge/moments, and "Indexed in Ns" for ~4s after completion before fading. - Per-file events arrive many per second on large libraries — throttled to 150 ms with a trailing-edge update so the pill stays calm and always lands on the most recent filename. Step and completion events bypass the throttle. - Filename slot is fixed at 24ch so the pill width stays constant through a run (no horizontal jitter as filenames change length); the full path is exposed via the parent's `title` for hover. - WS reconnect uses exponential backoff capped at 30 s, and the store tears down cleanly on logout so we don't leak sockets across identities. Defensive parsing throughout: PhotoPrism's WS protocol isn't a stable contract, so unknown event shapes are ignored rather than thrown — worst-case the pill stays idle. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../layout/IndexerStatusPill.svelte | 49 ++++ web/src/lib/stores/indexer.svelte.ts | 251 ++++++++++++++++++ web/src/routes/+layout.svelte | 16 +- 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/components/layout/IndexerStatusPill.svelte create mode 100644 web/src/lib/stores/indexer.svelte.ts diff --git a/web/src/lib/components/layout/IndexerStatusPill.svelte b/web/src/lib/components/layout/IndexerStatusPill.svelte new file mode 100644 index 0000000..865e435 --- /dev/null +++ b/web/src/lib/components/layout/IndexerStatusPill.svelte @@ -0,0 +1,49 @@ + + + +{#if indexer.active || indexer.label} +
+ {#if indexer.active} + + {/if} + {indexer.label} + {#if basename} + + + {basename} + + {/if} +
+{/if} diff --git a/web/src/lib/stores/indexer.svelte.ts b/web/src/lib/stores/indexer.svelte.ts new file mode 100644 index 0000000..42ab2f2 --- /dev/null +++ b/web/src/lib/stores/indexer.svelte.ts @@ -0,0 +1,251 @@ +import { browser } from '$app/environment'; +import { isAuthenticated, session } from './session.svelte'; + +/** + * Indexer status, populated from PhotoPrism's WebSocket channel + * (`/api/v1/ws`). Surfaces in the header pill so the user can see + * when the library is being scanned — whether triggered by a sidecar + * folder mutation, a manual reindex from PhotoPrism's own UI, or a + * scheduled cron. + * + * Tolerant by design: PhotoPrism's WS protocol isn't part of any + * stable contract, so unknown event shapes are ignored rather than + * thrown. The fields here are derived from the bundled PhotoPrism + * client's event names (`index`, `indexing`, `library_index`); if + * the protocol ever changes shape, the pill simply stays idle + * instead of breaking the app. + */ +interface IndexerState { + active: boolean; + /** Human-readable status, e.g. "Indexing", "Index complete". */ + label: string; + /** Optional sub-text — current file or folder being processed. */ + detail?: string; +} + +export const indexer = $state({ + active: false, + label: '' +}); + +let ws: WebSocket | null = null; +let reconnectTimer: ReturnType | null = null; +let backoffMs = 1000; +let clearTimer: ReturnType | null = null; + +// PhotoPrism fires `index.indexing` once per file — that's many events +// per second on a large library. Throttle UI updates from that stream +// to ~150 ms so the pill text doesn't flicker and Svelte isn't re-running +// the render scope on every other frame. Step / completion events bypass +// the throttle (they're already infrequent and the user benefits from +// seeing them land immediately). +const FILE_UPDATE_THROTTLE_MS = 150; +let lastFileUpdateAt = 0; +let pendingFileTimer: ReturnType | null = null; +let pendingFileName: string | undefined; + +function url(): string { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${location.host}/api/v1/ws`; +} + +function setActive(label: string, detail?: string): void { + if (clearTimer !== null) { + clearTimeout(clearTimer); + clearTimer = null; + } + indexer.active = true; + indexer.label = label; + indexer.detail = detail; +} + +/** + * Throttled variant of `setActive` for the per-file `index.indexing` + * stream. Leading-edge fires immediately so the user sees activity start + * without delay; subsequent events within FILE_UPDATE_THROTTLE_MS are + * coalesced into a single trailing-edge update so the displayed filename + * lands on the *most recent* file rather than some arbitrary one in the + * middle of the burst. + */ +function setActiveThrottled(label: string, fileName: string | undefined): void { + pendingFileName = fileName; + const now = Date.now(); + const since = now - lastFileUpdateAt; + if (since >= FILE_UPDATE_THROTTLE_MS) { + lastFileUpdateAt = now; + setActive(label, pendingFileName); + pendingFileName = undefined; + return; + } + if (pendingFileTimer !== null) return; + pendingFileTimer = setTimeout(() => { + pendingFileTimer = null; + lastFileUpdateAt = Date.now(); + setActive(label, pendingFileName); + pendingFileName = undefined; + }, FILE_UPDATE_THROTTLE_MS - since); +} + +function setCompleted(label = 'Index complete'): void { + // Drop any throttled `index.indexing` update still in flight — once + // PhotoPrism says "done" we don't want a stale filename to land on + // top of the completion label a few ms later. + if (pendingFileTimer !== null) { + clearTimeout(pendingFileTimer); + pendingFileTimer = null; + pendingFileName = undefined; + } + indexer.active = false; + indexer.label = label; + indexer.detail = undefined; + // Hold the "complete" label for a few seconds before clearing so the + // user gets a chance to see it; the pill collapses to nothing once + // label goes empty. + if (clearTimer !== null) clearTimeout(clearTimer); + clearTimer = setTimeout(() => { + if (!indexer.active) { + indexer.label = ''; + indexer.detail = undefined; + } + clearTimer = null; + }, 4000); +} + +function handleMessage(raw: string): void { + let msg: unknown; + try { + msg = JSON.parse(raw); + } catch { + return; + } + if (!msg || typeof msg !== 'object') return; + // PhotoPrism wraps events in `{event, data}`. Some versions nest the + // payload under `{msg: {event, data}}`; tolerate both. + const top = msg as Record; + const inner = (top.msg as Record | undefined) ?? top; + const eventName = inner.event as string | undefined; + const data = (inner.data ?? {}) as Record; + if (!eventName) return; + // PhotoPrism's WS protocol isn't a stable contract; log the live shape + // at `debug` (hidden by default in DevTools — toggle "Verbose" to see) + // so future-us can spot new indexer event names without instrumenting + // the entire app. + console.debug('[indexer]', eventName, data); + switch (eventName) { + case 'index.indexing': { + // Per-file event during the scan pass. PhotoPrism emits one + // of these per file it touches, so this fires often — route + // it through the throttled setter to keep the pill calm. + const fileName = + (data.fileName as string | undefined) ?? (data.baseName as string | undefined); + setActiveThrottled('Indexing', fileName); + return; + } + case 'index.updating': { + // Post-file passes (faces, counts, folders, purge, moments) + // run after the file scan finishes. They're cheap individually + // but the user sees them so showing the step name keeps the + // pill informative instead of looking stuck on the last file. + const step = String(data.step ?? ''); + setActive(step ? `Finalizing — ${step}` : 'Finalizing', undefined); + return; + } + case 'index.completed': { + const seconds = typeof data.seconds === 'number' ? data.seconds : undefined; + setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete'); + return; + } + default: + // Other events (notify.*, log.*, photos.updated, count.*, + // config.updated) are out of scope for the indexer pill. + // Intentionally ignored. + return; + } +} + +function connect(): void { + if (!browser) return; + if (!isAuthenticated()) return; + if (ws) return; + let sock: WebSocket; + try { + sock = new WebSocket(url()); + } catch { + scheduleReconnect(); + return; + } + sock.addEventListener('open', () => { + backoffMs = 1000; + // PhotoPrism authenticates the WS channel by reading the + // `auth_token` cookie set during login, but if the SPA is signed + // in via the legacy access-token store (no cookie), send an + // inline auth probe so the server can scope events to this + // session. Best-effort: failures don't block the listen path. + try { + sock.send( + JSON.stringify({ session: session.id, token: session.accessToken }) + ); + } catch { + /* best-effort; ignore */ + } + }); + sock.addEventListener('message', (e) => { + if (typeof e.data === 'string') handleMessage(e.data); + }); + sock.addEventListener('close', () => { + ws = null; + if (!isAuthenticated()) return; + scheduleReconnect(); + }); + sock.addEventListener('error', () => { + // Let `close` drive the reconnect path; logging on every transient + // error spams the console without adding signal. + }); + ws = sock; +} + +function scheduleReconnect(): void { + if (reconnectTimer !== null) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, backoffMs); + backoffMs = Math.min(backoffMs * 2, 30_000); +} + +/** Start listening to PhotoPrism's WS for indexer events. Idempotent — + * safe to call multiple times; only one socket is held at a time. */ +export function startIndexerWatch(): void { + if (!browser) return; + connect(); +} + +/** Tear down the WS and clear any displayed status. Call on logout. */ +export function stopIndexerWatch(): void { + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (clearTimer !== null) { + clearTimeout(clearTimer); + clearTimer = null; + } + if (pendingFileTimer !== null) { + clearTimeout(pendingFileTimer); + pendingFileTimer = null; + pendingFileName = undefined; + } + if (ws) { + try { + ws.close(1000); + } catch { + /* close errors are informational only */ + } + ws = null; + } + indexer.active = false; + indexer.label = ''; + indexer.detail = undefined; + backoffMs = 1000; + lastFileUpdateAt = 0; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1fe567f..7720916 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -14,6 +14,8 @@ import { resizable } from '$lib/actions/resizable'; import { queryClient } from '$lib/queryClient'; import { preview } from '$lib/stores/preview.svelte'; + import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte'; + import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte'; import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte'; import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte'; @@ -44,6 +46,16 @@ } }); + // Indexer status feed. The store opens a WS to PhotoPrism's /api/v1/ws + // channel once we're signed in and tears it down on logout. Kept here + // (not in the store's top-level) so SvelteKit's SSR pass never touches + // `WebSocket` and so logout can short-circuit reconnect attempts. + $effect(() => { + if (!browser || !bootstrapped) return; + if (isAuthenticated()) startIndexerWatch(); + else stopIndexerWatch(); + }); + // PreviewOverlay is the full-screen lightbox — keyboard nav, map // pane, exif sidebar. Users who never click into a photo never need // it, so we lazy-import the first time `preview.uid` flips non-null @@ -75,7 +87,9 @@ region inside each page scrolls. The mule header, toolbar, and sidebars stay fixed regardless of how far you scroll the grid. -->
- + + +
{#if !view.leftSidebarCollapsed}