Files
Announcements/README.md
T
MediaandClaude Opus 5 147e375702 Implement Phase 5A resilient display renderer
Build the real /display Chromium signage client as a React route on the
existing single-page build. The server remains authoritative; the display
only consumes the verified Phase 3B snapshot and reports telemetry.

The renderer owns two permanent scene layers that are never recreated. A
transition prepares the hidden layer, decodes the incoming image on the
element that will actually be composited, then raises that layer above the
current one at opacity 0 and fades it in while the outgoing layer stays
fully opaque underneath. This removes the v1 defect where two simultaneous
fades let the black root through the composite. Every asynchronous load
and transition carries a generation token, so work finishing after
authoritative state moved on is discarded rather than shown.

A true 16:9 slide fills the frame with no backdrop or shadow; anything
else is contained, centred, and uncropped over a blurred, solid, or black
background with a subtle shadow. Blur uses the global setting and is
overscanned; boolean darkening applies to the background only. Static
targets use the same renderer with the snapshot's global presentation
defaults, and mode off is a topmost gate while the renderer keeps
tracking underneath.

Slide bytes live in an explicit Cache Storage cache with reference
counted object URLs and conservative pruning, never in ordinary HTTP
caching. A new manifest is staged before promotion, so one unfetchable
asset marks only itself unavailable instead of rejecting a playlist. A
small IndexedDB store with explicit version handling holds the persistent
client ID, last known good manifest, timing anchor, and last structured
error, and discards malformed records on read. A narrow service worker
caches only the /display app shell, network-first for navigations, and
explicitly bypasses media, API, and socket traffic.

The display sends a real display:heartbeat every 12 seconds with its
actual state, and reports structured errors as bounded JSON that the
server's existing string validation already accepts; /api/displays parses
them back into an object, so no migration was required.

Verified against an isolated stack through Nginx with 20 Playwright
Chromium tests plus 9 client unit tests, and 187/187 backend tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:38:50 -05:00

506 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Announcements v2
Announcements is a small Express, React, Socket.IO, MQTT, SQLite, and
S3-compatible signage application. Version 2 is being built in phases and is
intended to run beside the existing production version until cutover.
## Development
The repository is a pnpm workspace with one lockfile. It requires Node.js
22.13 or newer and pnpm 10.29.3.
```sh
cp .env.example .env
pnpm install --frozen-lockfile
pnpm check
docker compose up --build
```
Only the Nginx web service publishes a host port (5175 by default). The API and
SeaweedFS S3 endpoint remain private on Compose networks. Replace the example
S3 credentials before starting the stack.
## Library assets
`POST /api/assets` accepts multipart uploads in the repeated `file` or `files`
field. An optional `display_name` (or `displayName`) is accepted for a
single-file upload. Supported decoded formats are still JPEG, PNG, and WebP;
extensions and multipart content types are not trusted. Animated, multipage,
malformed, unsupported, oversized, and excessive-pixel inputs are rejected.
Default limits are:
- 25 MiB per file (`UPLOAD_MAX_FILE_BYTES`)
- 20 files per request (`UPLOAD_MAX_FILES`)
- 40 million decoded pixels per image (`IMAGE_MAX_INPUT_PIXELS`)
Nginx accepts up to 512 MiB per request, enough for the default aggregate plus
multipart overhead. If the backend limits are raised, update
`client_max_body_size` in `client/nginx.conf` as well. Proxied API requests use
a ten-minute inactivity timeout so a valid large batch can finish processing.
Original bytes are archived unchanged. Content identity is the SHA-256 of
those bytes, so uploading the same content returns the existing asset even
when the filename differs. Display-name collisions with different content
return `409 ASSET_NAME_CONFLICT`. Multi-file uploads validate every new image
locally before persisting any of them. If a later storage operation fails,
already completed items remain valid and retrying the request is idempotent.
Sharp produces two metadata-free, auto-oriented WebP renditions:
- display: inside-fit at most 1920×1080, quality 82
- thumbnail: inside-fit at most 480×270, quality 72
Neither rendition crops or upscales. Transparency is flattened against black.
Presentation effects such as blur, shadows, darkening, and framing are not
baked into stored media.
## Asset and media API
- `GET /api/assets`
- `POST /api/assets`
- `GET /api/assets/:assetId`
- `PATCH /api/assets/:assetId`
- `GET /api/assets/:assetId/references`
- `DELETE /api/assets/:assetId`
- `GET /api/assets/:assetId/original`
- `GET /media/assets/:assetId/display/:sha256.webp`
- `GET /media/assets/:assetId/thumbnail/:sha256.webp`
PATCH accepts only display name and expiration. A null expiration is permanent.
Manual deletion refuses playlist, current playback, and static references. It
marks an asset as deleting before idempotently removing its three objects and
removes SQLite metadata only after storage succeeds. A failed processing row
can be inspected, safely deleted, and uploaded again; no automatic retry loop
is run.
Media URLs are derived from SQLite metadata and never accept object keys. The
display and thumbnail routes stream private S3 objects through the API with a
one-year immutable cache policy. Original downloads use private, no-store
caching.
## Playlists
Playlist and entry metadata is authoritative in SQLite. The API surface is:
- `GET /api/playlists`
- `POST /api/playlists`
- `GET /api/playlists/:playlistId`
- `PATCH /api/playlists/:playlistId`
- `DELETE /api/playlists/:playlistId`
- `POST /api/playlists/:playlistId/entries`
- `PATCH /api/playlists/:playlistId/entries/:entryId`
- `DELETE /api/playlists/:playlistId/entries/:entryId`
- `PUT /api/playlists/:playlistId/entries/order`
- `POST /api/playlists/:playlistId/activate`
Entry positions are zero-based and contiguous. Reordering requires the complete
ordered list as `{ "entryIds": [...] }`. An asset can occur only once in a
playlist, but the same asset can be used independently by any number of other
playlists. Only ready, unexpired assets can be newly added.
Entry duration overrides may be null (inherit the global setting) or 1,000 to
3,600,000 milliseconds. Presentation overrides are nullable; darkening is an
inherit/off/on boolean and blur always comes from the global setting. Schedule
instants use epoch milliseconds or ISO strings with an explicit `Z` or numeric
UTC offset, and intervals are evaluated as `[start, end)`.
Playlist detail responses include effective duration and presentation,
server-evaluated eligibility, immutable display/thumbnail URLs, and the
settings revision and evaluation time used. Playlist revisions increment once
per semantic content change. Exact no-ops and reads do not increment them.
Before a playback-relevant playlist, settings, or asset mutation, the playback
service catches up using the old metadata. The domain transaction then commits
and playback synchronously reconciles the new values at the mutation instant.
This prevents a late timer or restart boundary from being rewritten by a later
edit. If the post-commit reconciliation unexpectedly fails, the API returns an
error even though the preceding domain commit may already be durable; clients
must refetch current state before deciding whether to retry.
Activation is explicit. A request is held in the existing pending-activation
state while a playable normal slide is mid-duration; otherwise the target
playlist is prepared immediately at its first currently playable entry. The
output-off flag remains only a visual gate, and static hold is preserved while
the hidden normal playlist changes. The authoritative playback service consumes
a pending activation at the current slide boundary and never merges the old and
new playlists.
Socket.IO publishes `playlists:changed` as a refetch invalidation after
committed playlist changes and after settings or referenced-asset edits that
affect resolved playlist data. Socket.IO is not a mutation API.
## Authoritative playback
One serialized service owns persisted global playback, timer reconciliation,
and every playback control path. Its REST surface is:
- `GET /api/playback`
- `POST /api/playback/pause`
- `POST /api/playback/resume`
- `POST /api/playback/next`
- `POST /api/playback/previous`
- `POST /api/playback/static`
- `PUT /api/playback/mode`
- `POST /api/playback/reload`
Visual output precedence is mode off (black), a valid static asset, the current
normal entry, then black when no playable target exists. Mode off is only a
visual gate: normal playback continues underneath unless it is independently
paused or held by static mode. Pause and static both preserve the normal cursor
and remaining duration. Resume clears both static and paused state together and
continues the normal entry with its preserved remaining time. Next and previous
wrap across currently playable entries; they change the held entry without
unpausing when paused and are no-ops while static.
Normal slides use persisted absolute start times and deadlines. Chained
timeouts reconcile from the prior authoritative deadline, including late
callbacks and restart catch-up, so delays do not accumulate as timing drift.
The service also arms exact schedule and asset-expiration boundaries. It skips
disabled, out-of-schedule, expired, and non-ready entries, and safely returns to
an eligible entry when one becomes available. Pending playlist activation is
applied at the current normal slide boundary; an empty target playlist still
becomes active with black normal output.
Socket.IO sends `playback:state` immediately on connection and after meaningful
committed playback changes. The authoritative snapshot includes server time and
revisions, mode and transport state, active playlist and timeline data, current
and static targets, crossfade settings, and a display-cache manifest with
effective duration, presentation, scheduling, expiration, and immutable media
metadata. Enabled future-scheduled entries remain in the manifest; disabled
entries and private S3 details do not. Reads and connections do not increment
the playback revision.
Phase 3B exposes this state and behavior through REST and Socket.IO only. MQTT
command wiring and the browser display renderer remain later phases.
Deleting a library asset that is the current static target is still refused by
the Phase 2 deletion model. That refusal is a known interim integration point,
not the intended final behavior: deleting the statically held slide is meant to
exit static safely, return to normal rotation, and only then delete the asset.
Phase 3B does not depend on the refusal, so the change belongs to the later
phase that revisits asset lifecycle.
## MQTT integration
The API connects to `MQTT_URL` when it is configured and stays disabled when it
is not. Commands are dispatched straight into the authoritative playback
service, so an MQTT command and its REST equivalent are the same operation with
the same semantics; no playback logic is duplicated in the integration.
With the default `MQTT_TOPIC_PREFIX` of `announcements` the command topics are:
| Topic | Payload | QoS |
| ------------------------- | ---------------------- | --- |
| `announcements/mode` | `on` or `off` | 1 |
| `announcements/pause` | ignored | 1 |
| `announcements/resume` | ignored | 1 |
| `announcements/reload` | ignored | 1 |
| `announcements/static` | LibraryAsset UUID | 1 |
| `announcements/next` | ignored | 0 |
| `announcements/previous` | ignored | 0 |
Mode payloads are trimmed and case-insensitive. `off` is only the visual black
gate; it never pauses, restarts, or resets the playlist, cursor, static hold, or
timing. Static accepts only a stable LibraryAsset UUID: URLs, filenames, and S3
object keys are rejected. The asset must exist, be `ready`, and be unexpired,
but it does not need to appear in any playlist. A rejected command is logged as
a structured warning, changes nothing, and publishes no state.
`next` and `previous` stay at QoS 0 on purpose. They are not idempotent and
carry no command ID, so an at-least-once redelivery would silently skip a
slide. Commands are never retained, and the server never publishes onto a
command topic.
The server publishes retained state on separate topics:
| Topic | Payload |
| --------------------------------- | ---------------------------------- |
| `announcements/availability` | `online` / `offline` |
| `announcements/mode/state` | `on` / `off` |
| `announcements/playback/state` | small versioned JSON snapshot |
Availability describes this API's MQTT integration, not a display. A registered
last will and testament lets the broker publish `offline` when the API
disappears without a clean disconnect; a planned shutdown retracts it directly.
The compact playback topic carries mode, transport, active playlist, current
entry and asset, static asset, and revision. It never carries the Socket.IO
manifest or any private storage detail.
State is published only after a playback mutation has committed, and MQTT
publication happens after the SQLite commit. A publish failure degrades the
integration and is never allowed to roll playback back. A broker outage leaves
the API, HTTP, and playback untouched: the client reconnects on its own, then
resubscribes and republishes availability and authoritative state.
## Home Assistant notifications
Announcements sends outgoing generic webhook POSTs to `HA_WEBHOOK_URL`. Home
Assistant is only the receiver; Announcements never calls Companion directly and
needs no Home Assistant authentication beyond the configured webhook. The URL
and `HA_WEBHOOK_SECRET` are configuration, never API or status output.
Notifications are durable and deduplicated through `notification_incidents` and
`notification_outbox`. A condition that starts failing opens one incident and
queues exactly one failure notification. While it keeps failing only its
observation time is updated, so a thirty-minute outage still sends one message.
Recovery closes the incident and queues exactly one recovery notification. If
the same condition fails again later that is a new episode with its own
failure/recovery pair. Open incidents and undelivered notifications survive an
API restart.
Reported conditions are `storage.unavailable` / `storage.recovered`, driven by
the Phase 2 storage health transition rather than individual request failures;
`display.offline` / `display.recovered` per display client; and
`playback.no_playable_slides` / `playback.playable` from committed playback
state. A static override does not suppress the playback condition, because
holding one asset does not repair an empty playlist. A failed upload opens one
`asset.processing_failed` warning per asset; an individual failed upload has no
automatic recovery, so the operator resolves it by deleting or re-uploading.
The payload is versioned:
```json
{
"version": 1,
"notificationId": "stable-delivery-id",
"incidentId": "stable-incident-id",
"event": "storage.unavailable",
"severity": "critical",
"state": "failure",
"message": "Announcement storage is unavailable.",
"timestamp": "2026-08-16T12:00:00.000Z",
"context": { "conditionKey": "storage", "episode": 1 }
}
```
Severities are `info`, `warning`, `critical`, and `recovery`. Home Assistant can
treat them all as ordinary notifications; the field exists so later Ekklesia or
HA handling can discriminate.
Delivery is asynchronous and bounded. A 2xx marks the notification delivered.
Any other response and any transport failure leaves it pending and retries after
30 seconds, 1 minute, 5 minutes, 15 minutes, and 30 minutes, then every 30
minutes. The notification ID is stable across retries so a receiver can
deduplicate, retries resume after a restart, and a failing webhook never blocks
playback, MQTT, storage, or heartbeats. When no webhook URL is configured,
incidents are still recorded but no delivery work is queued, and status reports
the integration as disabled.
An example Home Assistant receiver:
```yaml
automation:
- alias: Announcements Notifications
triggers:
- trigger: webhook
webhook_id: announcements
allowed_methods:
- POST
local_only: true
actions:
- action: notify.mobile_app_YOUR_DEVICE
data:
title: "Announcements"
message: "{{ trigger.json.message }}"
```
Substitute any notify target; Companion is not required. Announcements cannot
send a webhook while the Announcements API itself is completely down. That is a
limitation of self-reporting, not a bug: if server-down alerting is wanted, an
external monitor such as Home Assistant should poll `/health/live`.
## Display telemetry
The future `/display` client reports read-only telemetry over the Socket.IO
event `display:heartbeat`, expected every 10-15 seconds:
```json
{
"protocolVersion": 1,
"clientId": "persistent-display-id",
"outputId": "global",
"activePlaylistId": "playlist-id",
"playlistRevision": 12,
"playbackRevision": 42,
"currentEntryId": "entry-id",
"currentAssetId": "asset-id",
"cachedAssetCount": 10,
"playableAssetCount": 10,
"mostRecentError": null
}
```
Every field is validated and bounded, `mostRecentError` is truncated, and
malformed telemetry is ignored without creating a display. Heartbeats can never
mutate playback and never increment a playback or playlist revision. Identity is
the persistent `clientId` stored in `display_clients`, never a Socket.IO
connection ID, so a browser reconnect remains the same display.
A display is online while heartbeats are arriving and offline once
`DISPLAY_OFFLINE_THRESHOLD_MS` (45 seconds, about three missed heartbeats) has
passed since the last one. A closed socket alone never marks a display offline,
because reconnects and brief network hiccups are normal. Crossing the threshold
opens one `display.offline` incident that does not repeat while the display
stays away, and the next valid heartbeat recovers it exactly once.
Display history is persisted, so a restart does not erase monitoring. The API
cannot know what happened to a display while the API itself was down, so it is
deliberately conservative: nothing is reported offline until a full threshold has
elapsed since startup. A healthy display that reconnects during that window is
never reported, a display that is still missing afterwards is reported once, and
an incident that was already open before the restart is not duplicated.
`GET /api/displays` returns each known display with a status derived from
heartbeat freshness rather than mere database existence, and exposes no socket
internals. `display:status` is emitted only for meaningful changes — first seen,
offline, recovered, or a changed error — so steady telemetry creates no realtime
churn. `mostRecentError` is telemetry only; arbitrary browser strings never
become Home Assistant notifications. Structured display-side errors such as
decode failures arrive with the Phase 5 renderer, which will report them through
this same field and event.
## Signage display
`/display` is the signage output and `/` is reserved for the future management
dashboard. Both are served by the same single-page build, so `/display` survives
direct navigation and refresh through the existing Nginx SPA fallback. The
display stays a Chromium kiosk pointed at a web application: v2 makes that
application reliable rather than replacing it with a native client.
Chromium should open `/display` in kiosk mode against an origin the browser
treats as secure — `http://localhost`, a private hostname over HTTPS, or the
equivalent. Cache Storage, Service Workers, and therefore offline behaviour are
unavailable on an insecure origin, and the display degrades to a network-only
renderer without them.
The old v1 kiosk flags that disabled the browser cache are no longer required
and should be removed at cutover. They existed because v1 reused mutable slide
URLs; v2 media URLs are content-addressed, so a changed image always arrives
under a new URL and stale bytes are impossible. Those flags now actively
interfere with the Service Worker and Cache Storage the display depends on.
### Transition guarantee
The renderer owns two permanent full-screen scene layers. Each layer is a
complete scene — background, optional darkening overlay, and foreground image —
and neither layer is ever recreated; only its contents and opacity change.
A transition prepares the hidden layer completely, decodes the incoming image
through `Image.decode()` on the element that will actually be composited, then
raises that layer above the current one at opacity 0 and fades it in. The
outgoing layer stays fully opaque underneath for the whole transition and is
never faded out. This is the specific v1 defect that produced black frames: two
simultaneous fades let the black root show through the composite.
If an incoming asset cannot be fetched or decoded, the last good frame simply
stays on screen. The entry is marked locally unavailable, a structured error is
recorded for telemetry, and rotation continues past it rather than retrying it
in a loop. Every asynchronous load and transition carries a generation token, so
work that completes after authoritative state has moved on is discarded instead
of overwriting the newer target.
While connected, the display schedules the expected next transition locally from
the authoritative timeline and the cached manifest, so transitions stay smooth
between packets. The server always wins any disagreement. When an activation is
pending for a playlist the display does not yet hold, local prediction is
withheld entirely: holding the current frame a little longer is better than
briefly showing the wrong slide.
### Presentation
A slide whose real pixel dimensions are 16:9 within a small tolerance fills the
frame: no blurred duplicate, no pillarbox, and no foreground shadow. Anything
else is shown whole and centred with `object-fit: contain`, never cropped, over
its configured background and with a subtle drop shadow.
Backgrounds follow the server's already-resolved effective values. `blurred`
reuses the same slide image, covered and centred, blurred by the global setting
(20px by default) and overscanned so the blur cannot reveal an edge. `solid`
fills with the configured colour and `black` fills with black. Background
darkening is boolean, resolved by the server from the global default and the
entry's inherit/off/on override, and applies a fixed overlay of about 0.25 to
the background only — never to the readable foreground. Blur is applied at
render time and is never baked into a stored derivative.
Static targets have no playlist entry, so they use the global presentation
defaults the snapshot already carries, through the same renderer and the same
transition path.
`mode=off` is a topmost black gate that covers the output immediately. The
renderer keeps tracking the timeline, caching, and updating its current target
underneath, so switching the mode back on reveals the synchronized scene without
restarting the playlist.
### Explicit asset cache
Slide bytes are stored explicitly in Cache Storage under
`announcements-display-assets-v1`, not left to ordinary HTTP caching. The asset
manager checks the cache, fetches only on a miss, validates the response, stores
a clone, and returns a blob that is decoded before it is ever presented. Object
URLs are reference counted, and bytes are never revoked while a scene layer is
still displaying them.
A new manifest never destroys the working one. Incoming assets are fetched and
validated first and the manifest is promoted once it is usable; one unfetchable
asset marks only itself unavailable rather than rejecting the whole playlist.
After promotion, anything not referenced by the promoted manifest, the current
static target, or a live scene layer is pruned. Quota pressure triggers a prune
and one retry before a bounded error is reported.
### Persistence and the app shell
A small IndexedDB database (`announcements-display`, version 1, one object
store, explicit upgrade handling) holds the persistent client ID, the
last-known-good manifest with the revisions needed to interpret it, the last
authoritative timing anchor, mode and transport state, and the most recent
structured error. Malformed or superseded records are discarded on read so the
display starts cleanly from the server instead of trusting an unreadable
snapshot. The client ID is generated once and reused across refresh, reconnect,
and Chromium restart; a socket ID is never used as display identity.
`display-sw.js` caches only the `/display` application shell. Navigations are
network-first so an online kiosk always receives the newest application, with
the cached shell as the offline fallback, and content-hashed Vite assets are
cache-first. The worker explicitly bypasses `/media`, `/api`, `/socket.io`, and
`/health`: slide bytes belong to the asset manager and live server state must
never be answered from a cache. It calls `skipWaiting` and `clients.claim`, so a
kiosk cannot be trapped on a stale version.
### Heartbeat and errors
The display emits `display:heartbeat` about every 12 seconds with its persistent
client ID, the active playlist and revisions it is actually rendering, the
current entry and asset, its true cached and locally playable asset counts, and
its most recent error. Telemetry is read-only and never mutates playback.
Errors are structured internally as `slide_decode_failed`, `asset_fetch_failed`,
`cache_write_failed`, or `manifest_invalid`, each with the relevant asset or
entry, a bounded message, and a timestamp. They are sent as bounded JSON, which
the server's existing string validation already accepts, so no schema migration
was required; `/api/displays` parses them back into an object. An error clears
when the failing asset renders successfully or leaves the manifest — never
merely because time passed.
## Storage and health
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
`S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY`.
Connection timeout, socket-inactivity timeout (`S3_REQUEST_TIMEOUT_MS`),
attempts, and background probe interval are configurable in `.env.example`.
Startup verifies or safely creates only the configured bucket.
Storage outages do not stop the API or fail process readiness. `/health/ready`
reports storage as degraded while remaining HTTP 200 when SQLite and the API
runtime are healthy. `/api/health` and `/api/status` expose cached, redacted
storage state and recover after a successful probe or storage operation.
`/health/live` reports only that the process is running. Readiness stays limited
to SQLite, migrations, and playback initialization. Every external integration
degrades the reported status instead of failing readiness, so a broker, webhook
receiver, or display problem can never put the API into a restart loop.
`/api/health` and `/api/status` report redacted storage, MQTT, notification,
playback, and display state. No MQTT password, S3 credential, webhook URL, or
webhook secret appears in any response.