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}