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,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;
}