feat(web): header pill showing PhotoPrism indexer status

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 — <step>" 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 23:13:46 +02:00
parent 3d8e050af4
commit e669e80a91
3 changed files with 315 additions and 1 deletions

View File

@@ -0,0 +1,49 @@
<!--
Compact status pill that appears in the header while PhotoPrism's
indexer is doing work. Driven by the indexer store, which subscribes
to PhotoPrism's WS channel. Renders nothing when idle so it never
steals header real estate from the user.
The `detail` (current path/file) is exposed via `title` rather than
rendered inline — the pill stays narrow even on slow flashes through
a deep library, and hover surfaces the detail for users who care.
-->
<script lang="ts">
import { indexer } from '$lib/stores/indexer.svelte';
import { Loader2 } from 'lucide-svelte';
// PhotoPrism's `fileName` arrives as the full relative path
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
// recognition; the full path stays in the `title` for users who hover.
const basename = $derived.by(() => {
const d = indexer.detail;
if (!d) return '';
const i = d.lastIndexOf('/');
return i >= 0 ? d.slice(i + 1) : d;
});
</script>
{#if indexer.active || indexer.label}
<div
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
title={indexer.detail ?? indexer.label}
role="status"
aria-live="polite"
>
{#if indexer.active}
<Loader2 class="h-3 w-3 animate-spin text-primary" />
{/if}
<span class="whitespace-nowrap">{indexer.label}</span>
{#if basename}
<!-- Fixed-width slot so the pill stops shrinking/growing as
PhotoPrism rattles through files of different name lengths.
`w-[24ch]` locks the column; `truncate` ellipsises anything
longer. The full path remains in the parent's `title`. -->
<span
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
>
{basename}
</span>
{/if}
</div>
{/if}

View File

@@ -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<IndexerState>({
active: false,
label: ''
});
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let backoffMs = 1000;
let clearTimer: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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<string, unknown>;
const inner = (top.msg as Record<string, unknown> | undefined) ?? top;
const eventName = inner.event as string | undefined;
const data = (inner.data ?? {}) as Record<string, unknown>;
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;
}

View File

@@ -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. -->
<div class="flex h-screen flex-col overflow-hidden">
<AnimatedMule />
<AnimatedMule>
<IndexerStatusPill />
</AnimatedMule>
<div class="flex min-h-0 flex-1">
{#if !view.leftSidebarCollapsed}
<aside