9 Commits
Author SHA1 Message Date
MediaandClaude Opus 5 423056e885 Implement Phase 6A visual management dashboard
Build the desktop management surface at / while leaving the verified
playback engine and display renderer untouched. Opening the dashboard goes
straight to the playlist that is actually active, with slide imagery
dominating rather than an admin table.

Playlist selection and activation are deliberately separate. Choosing a
playlist only changes what is being edited; an inactive playlist offers an
explicit Activate Playlist action. Activation remains the server's
decision: when playback queues the request to the current slide boundary
the header shows STARTING SOON and keeps the old playlist marked active
until authoritative state promotes it, so the UI never fakes activation
timing.

Entries render as large tiles in a responsive grid where position is the
sequence, so no ordinal numbers are stamped on slides. Names drop file
extensions and are uppercased in Roboto for presentation only; stored
display names keep their case. Status rings are green, gray, amber, or red
and are always accompanied by the same status as text, so nothing depends
on colour alone. Clicking a tile toggles that playlist entry only, never
the library asset or its use in other playlists. Tiles reorder by drag
through dnd-kit, persisting through the existing playlist API and snapping
back to the authoritative order if the server rejects the change. React
identity is always the stable PlaylistEntry UUID, never an index.

Uploads accept PNG, JPEG, and WebP from the picker or a drop anywhere on
the page, run sequentially so appended order matches selection order, and
report per-file state without blocking the dashboard. Content duplicates
and assets already in the playlist are surfaced rather than duplicated.
The Library panel browses and reuses existing assets, and permanent assets
are never shown as stale.

The live preview reuses the display's own presentation resolver and scene
stylesheet, which are extracted to a shared module so the miniature and
the signage output cannot drift apart. It takes on none of the kiosk's
authority: no worker, no offline rotation, no heartbeat, no identity.

Management is desktop-only below 768px, a restriction /display never
inherits.

Compose now attaches only the web service to the external tabernet network
as announcements-web, keeping the API and object storage private.

Verified with 200/200 backend, 20 client unit, and 44 Playwright tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 21:33:04 -05:00
MediaandClaude Opus 5 ba678d08d5 Complete Phase 5B offline display resilience
Make the /display kiosk survive losing its infrastructure, then surrender
authority cleanly when it returns. The Phase 5A renderer is unchanged: this
builds on its two permanent scene layers, explicit Cache Storage, IndexedDB,
Service Worker, timing anchors, and structured errors.

While connected the server is authoritative. While disconnected the display
becomes a deterministic local continuation of the last authoritative state
it persisted, and the moment the server speaks again the local cursor is
abandoned rather than reconciled or reported back.

A small client evaluator resolves offline playback from the persisted
manifest and clock anchor. Catch-up is arithmetic rather than iterative:
whole rotations are skipped between eligibility boundaries, so a month
offline resolves in milliseconds. Mixed durations, wraparound, [start, end)
schedules, and expirations are evaluated locally, and a null expiration
stays permanent. Mode off keeps the hidden cursor advancing, pause holds
its slide and remainder, static holds until the cached metadata says the
asset genuinely expired, and an activation queued for an unknown playlist
holds the last good frame instead of continuing into a playlist that is
being replaced. Contract tests run this evaluator and the real Phase 3B
engine over identical scenarios and assert the same slide, start, and
deadline, so the client tracks the server without refactoring it.

Cold start restores signage from the Service Worker shell, IndexedDB, and
Cache Storage with the entire stack down, keeping the same persistent
display identity.

Validated display error codes now become Home Assistant incidents through
the Phase 4 machinery, keyed per display and condition so a persisting
error reports once and recovers once. Arbitrary browser text stays
telemetry.

Corrects the deployment assumption: the kiosk runs on a separate machine,
so production requires a trusted HTTPS origin and localhost is development
only. Verifying on a real trusted certificate exposed two production bugs
that localhost had hidden — behind a reverse proxy an outage arrives as a
502 rather than a refused connection, and an unbounded shell fetch left a
restarting kiosk blank for the proxy's whole connect timeout. The worker
now treats a gateway error as an outage and bounds that wait.

Verified with 200/200 backend, 20 client unit, and 30 Playwright tests on
a trusted HTTPS origin with no browser security flags weakened.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 20:36:13 -05:00
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
MediaandClaude Opus 5 ae70bf1dd8 Implement Phase 4 integrations and display telemetry
Add MQTT playback control, durable Home Assistant incident notifications,
and display heartbeat telemetry on top of the verified Phase 3B playback
authority. No playback logic is duplicated: every MQTT command and every
observed condition goes through the existing playback service, and all
integration work happens after the authoritative SQLite commit.

MQTT (server/src/mqtt) subscribes the exact required command topics under
a configurable prefix that defaults to announcements. Durable commands use
QoS 1; next and previous stay at QoS 0 because they are not idempotent and
carry no command ID. Retained authoritative state is published only on the
separate availability, mode/state, and playback/state topics, never onto a
command topic, and only after a mutation commits. A registered last will
reports an ungraceful disappearance, a clean shutdown retracts it, and a
broker outage degrades status while playback, HTTP, and the display
protocol continue untouched.

Notifications (server/src/notifications) use the Phase 1 incident and
outbox tables. A failing condition opens one incident and queues exactly
one notification; continued failure only updates its observation time;
recovery queues exactly one recovery; a later failure is a new episode.
Delivery retries on a documented capped schedule with a stable notification
ID, survives restart, and never blocks playback. With no webhook URL
configured, incidents are still recorded but no delivery work is queued.
Conditions come from storage health transitions, committed playback state,
display heartbeat staleness, and newly failed uploads.

Displays (server/src/displays) ingest validated, bounded display:heartbeat
telemetry keyed by persistent client ID rather than socket ID. Telemetry is
read-only and never touches playback or playlist revisions. Offline is
decided by heartbeat staleness, not by a closed socket, and a startup grace
period keeps an API restart from manufacturing offline alerts. Adds
GET /api/displays and meaningful-change display:status events.

Health and status now report redacted storage, MQTT, notification,
playback, and display state. External integrations degrade status and never
affect readiness, so no integration outage can cause a restart loop. No
MQTT password, S3 credential, webhook URL, or webhook secret is exposed.

Uses the existing Phase 1 schema unchanged; no migration was required.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 22:52:59 -05:00
MediaandClaude Opus 5 993986c25e Complete Phase 3B authoritative playback
Audit, fix, and verify the Phase 3B playback engine under Node 22 with a
full backend suite, project lint/build checks, Docker builds, Compose
validation, and isolated runtime playback verification.

Two concrete correctness bugs were found and fixed, each with a
regression test that fails without the fix:

reconcileRotating() evaluated boundaries against a stale entry. After
fastForwardStable() skipped whole cycles it could move the stable cursor,
but the loop kept using the pre-skip entry, so that entry's schedule end
or expiration cut the successor's slide short and shifted every later
absolute deadline. Re-evaluate the loop when the cursor moves.

resumeHeldEntry() could reconstruct a timeline whose slide_started_at was
in the future. A remainder captured under a longer duration was replayed
verbatim after the effective duration shrank, publishing a snapshot whose
current slide had not started yet and whose remaining time exceeded its
own effective duration. Clamp the remainder to the effective duration.

Two Phase 3A tests built playback_state rows by raw SQL that the service
can never hold: an activation queued behind a playlist with no playable
entry, and a static hold that still carried a live absolute timeline.
Both now drive the authoritative service, keeping their original
assertions about deletion protection and revision bookkeeping.

Record the static-target deletion refusal as a known interim integration
point rather than final behavior; playback already tolerates a deleted
static asset, so Phase 3B does not depend on it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 22:22:03 -05:00
Media 68273d457f WIP: Phase 3B playback engine handoff 2026-08-16 21:50:19 -05:00
Media e2ecdc97e2 Implement Phase 3A playlist management 2026-08-16 20:34:36 -05:00
Media 722c6cf77d Implement Phase 2 asset storage pipeline 2026-08-16 19:59:10 -05:00
Media eb19675a5c Build Phase 1 application foundation 2026-08-16 19:16:45 -05:00
116 changed files with 25900 additions and 2161 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.gitignore
.agents
.codex
.env
.env.*
!.env.example
**/node_modules
.pnpm-store
**/dist
**/*.log
**/*.sqlite
**/*.sqlite-shm
**/*.sqlite-wal
**/*.sqlite-journal
data
backups
client/pnpm-lock.yaml
+61
View File
@@ -0,0 +1,61 @@
# A distinct project name and host port let v1 and v2 run side by side.
COMPOSE_PROJECT_NAME=announcements-v2
WEB_PORT=5175
# Docker Compose pins these internal values. They are shown here to document the
# standalone API configuration contract; do not uncomment them for normal Compose use.
# NODE_ENV=production
# HOST=0.0.0.0
# PORT=7532
# DATABASE_PATH=/data/db/announcements.sqlite
# S3_ENDPOINT=http://seaweedfs:8333
# S3_FORCE_PATH_STYLE=true
# Optional local Vite development proxy target (not used by Compose).
# VITE_API_PROXY_TARGET=http://127.0.0.1:7532
# Application configuration exposed by Compose
APP_TIMEZONE=America/Chicago
SHUTDOWN_TIMEOUT_MS=10000
DATABASE_BUSY_TIMEOUT_MS=5000
TRUST_PROXY=true
# Private SeaweedFS S3 configuration
S3_REGION=us-east-1
S3_BUCKET=announcements-v2
S3_ACCESS_KEY_ID=replace-with-a-random-access-key
S3_SECRET_ACCESS_KEY=replace-with-a-long-random-secret
S3_CONNECT_TIMEOUT_MS=2000
# Maximum socket inactivity during one S3 operation (not a total upload deadline).
S3_REQUEST_TIMEOUT_MS=5000
S3_MAX_ATTEMPTS=2
S3_HEALTH_PROBE_INTERVAL_MS=30000
# Image upload and decode limits. The default proxy body limit is 512 MiB,
# which accommodates the default twenty 25 MiB files plus multipart overhead.
UPLOAD_MAX_FILE_BYTES=26214400
UPLOAD_MAX_FILES=20
IMAGE_MAX_INPUT_PIXELS=40000000
# UPLOAD_TEMP_DIRECTORY=/tmp/announcements-uploads
# Optional integrations. Leave MQTT_URL blank to disable the MQTT integration
# and HA_WEBHOOK_URL blank to disable outgoing notifications entirely.
MQTT_URL=
MQTT_USERNAME=
MQTT_PASSWORD=
# With the default prefix the command topics are announcements/mode,
# announcements/next, announcements/previous, announcements/pause,
# announcements/resume, announcements/reload, and announcements/static.
MQTT_TOPIC_PREFIX=announcements
MQTT_RECONNECT_PERIOD_MS=5000
MQTT_CONNECT_TIMEOUT_MS=10000
# Outgoing Home Assistant webhook. The URL is a secret: it is never returned by
# any API response or status document.
HA_WEBHOOK_URL=
HA_WEBHOOK_SECRET=
HA_WEBHOOK_TIMEOUT_MS=10000
# A display is reported offline after this long without a valid heartbeat.
# The display client is expected to send one every 10-15 seconds.
DISPLAY_OFFLINE_THRESHOLD_MS=45000
+16 -1
View File
@@ -1 +1,16 @@
node_modules
node_modules/
.pnpm-store/
# Local configuration and secrets
.env
.env.*
!.env.example
# Local build and runtime data
dist/
*.sqlite
*.sqlite-shm
*.sqlite-wal
*.sqlite-journal
data/
backups/
+748
View File
@@ -0,0 +1,748 @@
# 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.
The web service also joins the external `tabernet` network under the alias
`announcements-web`, so a shared reverse proxy reaches it as
`announcements-web:80`. The API and SeaweedFS deliberately stay off that
network and remain private to Announcements. The network must already exist:
```sh
docker network create tabernet # only if it does not already exist
```
## 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.
## Management dashboard
`/` is the management dashboard and `/display` is the signage output. Both come
from the same single-page build; the dashboard's styling is scoped to its own
route and never applies to the kiosk.
Management is desktop-only in v2. Below 768px the dashboard shows a short
splash asking for a larger screen. That restriction never applies to
`/display`, which renders signage on any screen including a phone.
Opening `/` goes straight to the playlist that is actually active — no playlist
has to be chosen first. The header carries the playlist switcher, the
activation state, the display status, and the upload and library actions.
### Playlists and activation
The switcher changes which playlist is being *edited*. Selecting a playlist
never activates it: an inactive playlist shows `NOT ACTIVE` with an
**Activate Playlist** button, and activation goes through
`POST /api/playlists/:playlistId/activate`.
Activation is the server's decision, not the dashboard's. If playback queues the
request to the end of the current slide, the header shows `STARTING SOON` and
keeps showing the old playlist as active until authoritative playback promotes
it. The dashboard never claims a playlist is active before the server says so.
Basic playlist creation is available from the header.
### Slide tiles
Entries render as large visual tiles in a responsive grid. Position in the grid
is the sequence, so tiles carry no ordinal numbers. Names are shown without file
extensions and uppercased visually in Roboto; the stored display name keeps its
original case.
Each tile carries a status ring, always accompanied by the same status in text
so nothing depends on colour alone:
| Ring | Meaning |
| --- | --- |
| Green | Enabled and currently playable |
| Gray | Disabled |
| Amber | Enabled but outside its scheduling window |
| Red | The asset is unavailable or expired |
Clicking a tile toggles that **playlist entry's** enabled state. It never
changes the library asset, and the same asset stays independently enabled in
other playlists. Tiles are dragged to reorder, and the new order is persisted
through the playlist API; if the server rejects it, the authoritative order is
restored and a message explains why. React identity is always the stable
PlaylistEntry UUID, never an array index or position.
An empty playlist and a playlist whose slides are all currently unplayable are
different situations, and the dashboard says which one it is.
### Uploads and the library
**Upload Slides** accepts PNG, JPEG, and WebP through the file picker or by
dropping files anywhere on the dashboard. Multiple files upload in the order
they were selected and are appended to the end of the playlist. Each file shows
its own state — queued, uploading, processing, added, already in the library,
already in this playlist, or failed — and the dashboard stays usable throughout.
PDFs and other formats are refused before upload.
Content identity is the SHA-256 of the original bytes, so re-uploading the same
image reuses the existing library asset instead of storing another copy. A
matching filename with different content is a different asset and is never
overwritten. An asset can appear in many playlists but only once per playlist.
The **Library** panel browses existing assets with a name filter, so images can
be reused without re-uploading. Assets already in the current playlist are shown
as such and cannot be added twice. An asset with no expiration is permanent and
is never presented as stale; one with an expiration shows when it expires.
### Live preview and status
The dashboard shows a miniature of the real signage output driven by
authoritative `playback:state`. It is not a bare thumbnail: it reuses the same
presentation resolver and the same scene stylesheet as `/display`, so 16:9 fill,
uncropped containment, blurred, solid, and black backgrounds, darkening,
foreground shadow, static targets, and the mode-off black gate all render with
identical semantics and cannot drift apart. The preview deliberately does not
take on kiosk responsibilities — no Service Worker, no offline rotation, no
heartbeat, and no display identity of its own.
Alongside it, one compact indicator reports whether the kiosk is actually
online, with last-seen and cached-slide detail, plus the current transport state
(rotating, paused, static, or output off). Transport is read-only here; MQTT and
the API remain the control paths.
## 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.
### Deployment topology
The kiosk runs on a different computer from the Announcements host, so the
localhost secure-context exception is not available in production. A remote
display **must** use HTTPS:
```
Remote Chromium kiosk
|
HTTPS
|
v
Announcements web / reverse proxy
|
+-- React /display
+-- /api
+-- /socket.io
+-- /media
```
The reverse proxy terminates TLS; the API and web containers may keep speaking
plain HTTP behind it. The kiosk browser must trust the certificate — a public
certificate or an internal CA installed on the kiosk machine both work.
Secure-context rules decide what the display can do:
| Origin | Status |
| --- | --- |
| `https://display.example.org/display` | Production. Full offline capability. |
| `http://localhost:5175/display` | Development and automated testing only. Secure context, so offline works. |
| `http://10.0.0.5:5175/display` | Degraded, online only. **Not a valid kiosk configuration.** |
Plain LAN HTTP has no Service Worker, no Cache Storage, and no offline startup.
The display still shows slides while the server is reachable, but it cannot
survive an outage. It detects this at startup and reports
`offline_capability_unavailable` through its heartbeat so the limitation is
visible in `/api/displays` and Home Assistant rather than on screen.
Do not work around this with `--ignore-certificate-errors`,
`--disable-web-security`, or an insecure-origin-as-secure flag. They disable
the protections the offline design relies on and do not represent a supportable
deployment.
### Kiosk browser requirements
The kiosk must use a **persistent** Chromium profile. The Service Worker, Cache
Storage, and IndexedDB that make offline operation possible all live in that
profile, and an incognito window, a guest profile, or a throwaway
`--user-data-dir` discards them on every launch.
```sh
chromium \
--kiosk \
--user-data-dir=/var/lib/announcements-kiosk \
https://display.example.org/display
```
The old v1 kiosk flags that disabled the browser cache are no longer required
and must 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.
Clearing the browser's site data deletes the cached slides, the last-known-good
state, and the display's identity. Nothing is lost permanently: on its next
connection the display registers as a new client, re-caches the active playlist,
and resumes. No routine cache management is expected of an operator.
### 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.
### Offline resilience
While connected the server is authoritative. While disconnected the display
becomes a deterministic local continuation of the last authoritative state it
persisted. The moment the server speaks again it is authoritative once more:
the local cursor is abandoned, never reconciled and never reported back.
Offline playback is resilience, not distributed consensus.
Local continuation uses the persisted manifest and timing anchor. Elapsed time
is derived from the anchor — the authoritative `serverTime` and the local clock
reading when it was accepted — so a restart estimates where playback should be
rather than restarting a slide. Catch-up is arithmetic: whole rotations are
skipped in one step between eligibility boundaries, so a display offline for a
month resolves its position in milliseconds instead of stepping through every
missed slide. Mixed durations, wraparound, `[start, end)` schedule boundaries,
and asset expirations are all evaluated locally from the cached manifest. A null
expiration stays permanent no matter how long the display has been away.
Transport state is preserved exactly. Mode off stays black while the hidden
cursor keeps advancing; a paused display holds its slide and remainder and never
advances merely because time passed or the browser restarted; static holds
indefinitely and releases only when the cached metadata says the asset genuinely
expired. If the server had queued an activation for a playlist the display does
not hold, the display holds its last good frame at that boundary rather than
continuing deeper into a playlist that is being replaced.
The distinction between "the playlist is genuinely empty" and "the playlist
should play but the local bytes are unusable" is preserved: the first is
correctly black, the second keeps the last good frame and reports an error.
This evaluator is a small client-side implementation, deliberately separate from
the verified Phase 3B engine. Contract tests run both over identical scenarios —
mixed durations, wraparound, schedule starts and ends, expirations, long
outages, and empty playlists — and assert they resolve the same slide, start,
and deadline.
### Cold start
After one successful online visit the display can start with the entire stack
unavailable. The Service Worker serves the application shell, IndexedDB
provides the client ID and last-known-good state, Cache Storage provides the
slide bytes, and local playback resumes from the persisted anchor. A reconnect
attempt continues in the background.
A display that has never successfully cached anything has nothing trustworthy
to show, so it stays black. It never invents placeholder content.
Because a remote kiosk reaches the origin through a reverse proxy, an outage
behind that proxy arrives as a 502 rather than a refused connection. The worker
treats a gateway error as an outage, and bounds how long it waits for the
network when a usable shell is already cached, so a kiosk restarting during an
outage reaches its slides quickly instead of waiting out a proxy timeout.
### Reconnect
On reconnect the authoritative snapshot wins. Required assets are staged and
decoded first and the current frame is kept until the new one is ready, so
resynchronization never exposes black and never replays the slides that were
missed. If the local calculation already agrees with the server, the timing
anchor is simply re-anchored with no visual churn. A playlist changed during the
outage is staged and adopted without mixing old and new entries, and the
superseded playlist's bytes are pruned once the new manifest is promoted.
The server's own 45-second heartbeat threshold is unchanged. A display can be
showing announcements perfectly while disconnected and still be reported as
`display.offline`, which is the useful distinction: the audience sees slides,
and the operator still learns that the display lost contact.
### Display errors as incidents
Validated display error codes become Home Assistant incidents through the
existing Phase 4 machinery, keyed per display and condition so a persisting
error is reported once and recovers once:
| Code | Severity |
| --- | --- |
| `slide_decode_failed` | warning |
| `asset_fetch_failed` | warning |
| `cache_write_failed` | warning |
| `manifest_invalid` | critical |
| `offline_capability_unavailable` | warning |
Arbitrary browser text remains telemetry and never becomes a notification. If
an error occurs while the display is completely disconnected the server cannot
learn of it at that instant; the display persists its current error and reports
it on reconnect. A transient offline error that fully resolves before
reconnection therefore produces no historical notification. This is a documented
limitation rather than a browser-side durable event queue.
## 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.
+7
View File
@@ -22,3 +22,10 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Playwright browser test artifacts
test-results/
playwright-report/
blob-report/
.playwright/
debug.mjs
+18 -8
View File
@@ -1,14 +1,24 @@
FROM node:22-alpine AS build
FROM node:22.23.2-bookworm-slim AS build
ARG PNPM_VERSION=10.29.3
WORKDIR /app
RUN corepack enable
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY server/package.json ./server/package.json
COPY client/package.json ./client/package.json
COPY . .
RUN pnpm build
RUN pnpm install --frozen-lockfile --filter @announcements/client...
COPY client ./client
RUN pnpm --filter @announcements/client build
FROM nginx:1.30.4-alpine
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/client/dist /usr/share/nginx/html
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+589
View File
@@ -0,0 +1,589 @@
import { expect, test } from "@playwright/test";
import { ensureFixtures, makePng } from "./fixtures.js";
import { api, resetPlayback } from "./helpers.js";
const BASE = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
async function freshPlaylist(request, name, assetIds) {
const playlist = await api(request, "/api/playlists", {
method: "POST",
data: { name: `${name} ${Date.now()}-${Math.random().toString(16).slice(2, 7)}` },
});
for (const assetId of assetIds) {
await api(request, `/api/playlists/${playlist.id}/entries`, {
method: "POST",
data: { assetId, durationOverrideMs: 60_000 },
});
}
return api(request, `/api/playlists/${playlist.id}`);
}
async function openDashboardOn(page, playlistName) {
await page.goto(`${BASE}/`);
await page.waitForSelector('[data-testid="playlist-select"]');
await page.selectOption('[data-testid="playlist-select"]', { label: playlistName });
await expect(page.locator("h1")).toHaveText(playlistName);
}
function tiles(page) {
return page.locator('[data-testid="tile"]');
}
test.beforeEach(async ({ request }) => {
await ensureFixtures(request);
await resetPlayback(request);
});
test("the dashboard opens on the active playlist and the signage route still works", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Active open", [
library["wide-a"].id,
library["wide-b"].id,
]);
const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
method: "POST",
});
if (activation.activation === "pending") {
await api(request, "/api/playback/next", { method: "POST" });
}
await page.goto(`${BASE}/`);
// No playlist has to be chosen before something is visible.
await expect(page.locator("h1")).toHaveText(playlist.name, { timeout: 30_000 });
await expect(page.getByTestId("activation-state")).toHaveAttribute("data-state", "active");
await expect(tiles(page)).toHaveCount(2);
await expect(page.getByTestId("scene-preview")).toBeVisible();
// The signage route is untouched by the dashboard.
await page.goto(`${BASE}/display`);
await expect(page.getByTestId("display-root")).toBeVisible();
await expect(page.getByTestId("dashboard")).toHaveCount(0);
});
test("selecting another playlist never activates it; activation is explicit", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const active = await freshPlaylist(request, "Stays active", [library["wide-a"].id]);
const other = await freshPlaylist(request, "Guest Church", [library["wide-b"].id]);
const activation = await api(request, `/api/playlists/${active.id}/activate`, {
method: "POST",
});
if (activation.activation === "pending") {
await api(request, "/api/playback/next", { method: "POST" });
}
await openDashboardOn(page, other.name);
await expect(page.getByTestId("activation-state")).toHaveAttribute("data-state", "inactive");
await expect(page.getByTestId("activate-button")).toBeVisible();
// Merely viewing it must not have changed the server's active playlist.
expect((await api(request, "/api/playback")).activePlaylistId).toBe(active.id);
await page.getByTestId("activate-button").click();
// The server decides immediate versus queued; the UI must not fake either.
await expect
.poll(async () => page.getByTestId("activation-state").getAttribute("data-state"), {
timeout: 30_000,
})
.toMatch(/^(active|pending)$/);
const state = await page.getByTestId("activation-state").getAttribute("data-state");
const playback = await api(request, "/api/playback");
if (state === "pending") {
expect(playback.pendingPlaylistId).toBe(other.id);
expect(playback.activePlaylistId).toBe(active.id);
// Once the boundary passes, authoritative state promotes it with no reload.
await api(request, "/api/playback/next", { method: "POST" });
}
await expect
.poll(async () => page.getByTestId("activation-state").getAttribute("data-state"), {
timeout: 45_000,
})
.toBe("active");
expect((await api(request, "/api/playback")).activePlaylistId).toBe(other.id);
});
test("tiles show artwork, clean names, and accurate status without relying on colour", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Statuses", [
library["wide-a"].id,
library["wide-b"].id,
library["wide-c"].id,
]);
await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[1].id}`, {
method: "PATCH",
data: { enabled: false },
});
await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[2].id}`, {
method: "PATCH",
data: { scheduleStartAt: Date.now() + 86_400_000 },
});
await openDashboardOn(page, playlist.name);
await expect(tiles(page)).toHaveCount(3);
const rings = await tiles(page).evaluateAll((nodes) =>
nodes.map((node) => ({
ring: node.dataset.ring,
entryId: node.dataset.entryId,
name: node.querySelector(".tile-name").textContent,
status: node.querySelector('[data-testid="tile-status"]').textContent,
transform: getComputedStyle(node.querySelector(".tile-name")).textTransform,
font: getComputedStyle(node.querySelector(".tile-name")).fontFamily,
hasImage: Boolean(node.querySelector("img.tile-image")),
label: node.querySelector(".tile-surface").getAttribute("aria-label"),
})),
);
expect(rings.map((tile) => tile.ring)).toEqual(["playable", "disabled", "scheduled"]);
expect(rings[0].status).toBe("Enabled");
expect(rings[1].status).toBe("Disabled");
expect(rings[2].status).toContain("Scheduled");
// Status is described in text as well as colour, and reaches the accessible name.
for (const tile of rings) expect(tile.label).toContain(tile.status);
for (const tile of rings) {
expect(tile.hasImage).toBe(true);
expect(tile.name, "file extensions are never shown").not.toMatch(/\.(png|jpe?g|webp)$/i);
// Uppercase is presentation only; the stored name keeps its case.
expect(tile.transform).toBe("uppercase");
expect(tile.font).toContain("Roboto");
}
const stored = await api(request, `/api/playlists/${playlist.id}`);
expect(stored.entries[0].asset.displayName).toBe(library["wide-a"].displayName);
// Stable PlaylistEntry UUIDs are the identity, never positions.
expect(rings.map((tile) => tile.entryId)).toEqual(
playlist.entries.map((entry) => entry.id),
);
for (const tile of rings) expect(tile.entryId).toMatch(/^[0-9a-f-]{36}$/);
});
test("clicking a tile toggles only that playlist entry", async ({ page, request }) => {
const library = await ensureFixtures(request);
const first = await freshPlaylist(request, "Toggle A", [library["wide-a"].id]);
const second = await freshPlaylist(request, "Toggle B", [library["wide-a"].id]);
await openDashboardOn(page, first.name);
const tile = tiles(page).first();
await expect(tile).toHaveAttribute("data-ring", "playable");
await tile.locator(".tile-surface").click();
await expect(tile).toHaveAttribute("data-ring", "disabled");
// Server-confirmed, and scoped to this playlist entry only.
const reloadedFirst = await api(request, `/api/playlists/${first.id}`);
expect(reloadedFirst.entries[0].enabled).toBe(false);
const reloadedSecond = await api(request, `/api/playlists/${second.id}`);
expect(reloadedSecond.entries[0].enabled, "the same asset elsewhere is untouched").toBe(true);
expect(
(await api(request, `/api/assets/${library["wide-a"].id}`)).status,
"the library asset itself is never modified",
).toBe("ready");
await tile.locator(".tile-surface").click();
await expect(tile).toHaveAttribute("data-ring", "playable");
});
test("dragging a tile reorders the playlist and the order survives a refresh", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Reorder", [
library["wide-a"].id,
library["wide-b"].id,
library["wide-c"].id,
library["wide-d"].id,
]);
await openDashboardOn(page, playlist.name);
await expect(tiles(page)).toHaveCount(4);
const originalIds = await tiles(page).evaluateAll((nodes) =>
nodes.map((node) => node.dataset.entryId),
);
// Drag the first tile onto the last position.
const source = tiles(page).nth(0).locator('[data-testid="tile-drag"]');
const target = tiles(page).nth(3);
const from = await source.boundingBox();
const to = await target.boundingBox();
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 25 });
await page.mouse.up();
const expected = [originalIds[1], originalIds[2], originalIds[3], originalIds[0]];
await expect
.poll(
async () => tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)),
{ timeout: 20_000 },
)
.toEqual(expected);
// The backend holds the same order with contiguous zero-based positions.
const stored = await api(request, `/api/playlists/${playlist.id}`);
expect(stored.entries.map((entry) => entry.id)).toEqual(expected);
expect(stored.entries.map((entry) => entry.position)).toEqual([0, 1, 2, 3]);
// A reload correctly reopens on the active playlist, so navigate back to the
// one being edited and confirm the order persisted.
await page.reload();
await openDashboardOn(page, playlist.name);
await expect
.poll(async () =>
tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)),
)
.toEqual(expected);
});
test("a rejected reorder restores the authoritative order without losing the grid", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Reorder fail", [
library["wide-a"].id,
library["wide-b"].id,
library["wide-c"].id,
]);
await openDashboardOn(page, playlist.name);
const originalIds = await tiles(page).evaluateAll((nodes) =>
nodes.map((node) => node.dataset.entryId),
);
await page.route("**/entries/order", (route) =>
route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({ error: { code: "PLAYLIST_ORDER_INCOMPLETE", message: "stale" } }),
}),
);
const source = tiles(page).nth(0).locator('[data-testid="tile-drag"]');
const target = tiles(page).nth(2);
const from = await source.boundingBox();
const to = await target.boundingBox();
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 20 });
await page.mouse.up();
await expect(page.getByTestId("toast")).toContainText("Could not save the new order");
await expect
.poll(async () =>
tiles(page).evaluateAll((nodes) => nodes.map((node) => node.dataset.entryId)),
)
.toEqual(originalIds);
expect((await api(request, `/api/playlists/${playlist.id}`)).entries.map((e) => e.id)).toEqual(
originalIds,
);
});
test("uploads append in selection order and report duplicates without blocking", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Uploads", [library["wide-a"].id]);
await openDashboardOn(page, playlist.name);
// Content identity is the SHA-256 of the bytes, so each run needs genuinely
// new pixels; reusing fixture bytes would correctly deduplicate instead.
const unique = `${Date.now()}`;
const seed = Date.now() % 200;
const files = [
{ name: `alpha-${unique}.png`, mimeType: "image/png", buffer: makePng(1920, 1080, [seed, 20, 30]) },
{ name: `beta-${unique}.png`, mimeType: "image/png", buffer: makePng(1920, 1080, [40, seed, 60]) },
{ name: `gamma-${unique}.png`, mimeType: "image/png", buffer: makePng(1200, 1200, [70, 80, seed]) },
];
await page.getByTestId("file-input").setInputFiles(files);
// The dashboard stays usable while uploads run.
await expect(page.getByTestId("upload-queue")).toBeVisible();
await expect(page.getByTestId("playlist-select")).toBeEnabled();
await expect(tiles(page)).toHaveCount(4, { timeout: 90_000 });
const names = await tiles(page).evaluateAll((nodes) =>
nodes.map((node) => node.querySelector(".tile-name").textContent),
);
// Appended at the end, in the order they were selected.
expect(names.slice(1)).toEqual([
`alpha-${unique}`.toLowerCase(),
`beta-${unique}`.toLowerCase(),
`gamma-${unique}`.toLowerCase(),
]);
await expect(page.locator('[data-testid="upload-item"][data-state="complete"]')).toHaveCount(3);
// Re-uploading identical bytes is recognised as content already present.
await page.getByTestId("file-input").setInputFiles([files[0]]);
await expect(
page.locator('[data-testid="upload-item"][data-state="already_in_playlist"]'),
).toHaveCount(1, { timeout: 60_000 });
await expect(tiles(page), "no duplicate entry is created").toHaveCount(4);
// A PDF is refused before it reaches the upload pipeline.
await page.getByTestId("file-input").setInputFiles([
{ name: "handout.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4") },
]);
await expect(page.locator('[data-testid="upload-item"][data-state="failed"]')).toContainText(
"PNG, JPEG, and WebP",
);
await expect(tiles(page)).toHaveCount(4);
});
test("dropping files onto the dashboard uploads them", async ({ page, request }) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Drop", [library["wide-a"].id]);
await openDashboardOn(page, playlist.name);
const unique = `drop-${Date.now()}`;
// Unique bytes so this is a new asset rather than a content duplicate.
const buffer = makePng(1920, 1080, [Date.now() % 200, 45, 67]).toString("base64");
await page.evaluate(
async ([name, base64]) => {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const file = new File([bytes], `${name}.png`, { type: "image/png" });
const transfer = new DataTransfer();
transfer.items.add(file);
const root = document.querySelector('[data-testid="dashboard"]');
root.dispatchEvent(new DragEvent("dragover", { dataTransfer: transfer, bubbles: true }));
root.dispatchEvent(new DragEvent("drop", { dataTransfer: transfer, bubbles: true }));
},
[unique, buffer],
);
await expect(tiles(page)).toHaveCount(2, { timeout: 90_000 });
await expect(tiles(page).last().locator(".tile-name")).toHaveText(unique);
});
test("the library reuses existing assets and refuses duplicates within a playlist", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Library reuse", [library["wide-a"].id]);
await openDashboardOn(page, playlist.name);
await page.getByTestId("library-button").click();
await expect(page.getByTestId("library-panel")).toBeVisible();
// An asset already in this playlist cannot be added twice.
const already = page.locator(`[data-testid="library-item"][data-asset-id="${library["wide-a"].id}"]`);
await expect(already).toHaveAttribute("data-in-playlist", "true");
await expect(already.locator("button")).toBeDisabled();
// A permanent asset must never be presented as expired.
await expect(already).not.toContainText("Expired");
// Add two different assets at once.
await page.locator(`[data-testid="library-item"][data-asset-id="${library["wide-b"].id}"] button`).click();
await page.locator(`[data-testid="library-item"][data-asset-id="${library.square.id}"] button`).click();
await page.getByRole("button", { name: /Add 2 to playlist/ }).click();
await expect(tiles(page)).toHaveCount(3, { timeout: 30_000 });
const stored = await api(request, `/api/playlists/${playlist.id}`);
expect(stored.entries.map((entry) => entry.assetId)).toEqual([
library["wide-a"].id,
library["wide-b"].id,
library.square.id,
]);
// The same asset is free to appear in a different playlist.
const other = await freshPlaylist(request, "Library reuse other", [library["wide-a"].id]);
expect(other.entries[0].assetId).toBe(library["wide-a"].id);
});
test("the live preview renders slides with the same presentation as the signage output", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Preview", [library.portrait.id]);
await api(request, `/api/playlists/${playlist.id}/entries/${playlist.entries[0].id}`, {
method: "PATCH",
data: {
backgroundModeOverride: "blurred",
backgroundDarkenedOverride: true,
},
});
const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
method: "POST",
});
if (activation.activation === "pending") {
await api(request, "/api/playback/next", { method: "POST" });
}
const read = (target) =>
target.evaluate(() => {
const layer = document.querySelector('.scene[data-visible="true"]');
const background = layer.querySelector(".scene-background-image");
const foreground = layer.querySelector(".scene-foreground");
return {
fit: layer.dataset.fit,
backgroundMode: layer.dataset.backgroundMode,
darkened: layer.dataset.darkened,
shadow: layer.dataset.shadow,
backgroundDisplay: getComputedStyle(background).display,
backgroundFit: getComputedStyle(background).objectFit,
backgroundFilter: getComputedStyle(background).filter,
foregroundFit: getComputedStyle(foreground).objectFit,
foregroundFilter: getComputedStyle(foreground).filter,
darkenOpacity: Number(getComputedStyle(layer.querySelector(".scene-darken")).opacity),
};
});
await page.goto(`${BASE}/`);
await expect(page.getByTestId("scene-preview")).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () => page.locator('.scene[data-visible="true"]').count(), { timeout: 30_000 })
.toBe(1);
const preview = await read(page);
const kiosk = await page.context().newPage();
await kiosk.goto(`${BASE}/display`);
await expect
.poll(async () => kiosk.locator('.scene[data-visible="true"]').count(), { timeout: 45_000 })
.toBeGreaterThanOrEqual(1);
await kiosk.waitForTimeout(1_500);
const signage = await read(kiosk);
await kiosk.close();
// Proving shared semantics rather than a screenshot likeness.
expect(preview).toEqual(signage);
expect(preview.fit).toBe("contain");
expect(preview.backgroundMode).toBe("blurred");
expect(preview.backgroundFilter).toBe("blur(20px)");
expect(preview.darkenOpacity).toBeCloseTo(0.25, 2);
});
test("the preview follows mode off and 16:9 fill exactly like the signage output", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Preview modes", [library["wide-a"].id]);
const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
method: "POST",
});
if (activation.activation === "pending") {
await api(request, "/api/playback/next", { method: "POST" });
}
await page.goto(`${BASE}/`);
await expect(page.getByTestId("scene-preview")).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () => page.locator('.scene[data-visible="true"]').getAttribute("data-fit"), {
timeout: 30_000,
})
.toBe("fill");
// A true 16:9 slide fills with no blurred duplicate and no card shadow.
const wide = await page.evaluate(() => {
const layer = document.querySelector('.scene[data-visible="true"]');
return {
backgroundMode: layer.dataset.backgroundMode,
shadow: layer.dataset.shadow,
backgroundDisplay: getComputedStyle(layer.querySelector(".scene-background-image")).display,
foregroundFilter: getComputedStyle(layer.querySelector(".scene-foreground")).filter,
};
});
expect(wide).toEqual({
backgroundMode: "none",
shadow: "false",
backgroundDisplay: "none",
foregroundFilter: "none",
});
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "off" } });
await expect(page.getByTestId("preview-gate")).toHaveAttribute("data-active", "true", {
timeout: 30_000,
});
await expect(page.getByTestId("playback-status")).toContainText("Output Off");
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
await expect(page.getByTestId("preview-gate")).toHaveAttribute("data-active", "false", {
timeout: 30_000,
});
});
test("the dashboard reflects a real display coming online and going offline", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const playlist = await freshPlaylist(request, "Display status", [library["wide-a"].id]);
const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
method: "POST",
});
if (activation.activation === "pending") {
await api(request, "/api/playback/next", { method: "POST" });
}
await page.goto(`${BASE}/`);
await expect(page.getByTestId("display-status")).toBeVisible({ timeout: 30_000 });
// A real kiosk page produces genuine heartbeats.
const kiosk = await page.context().newPage();
await kiosk.goto(`${BASE}/display`);
await expect
.poll(async () => page.getByTestId("display-status").getAttribute("data-online"), {
timeout: 60_000,
})
.toBe("true");
await expect(page.getByTestId("display-status")).toContainText("Display Online");
await kiosk.close();
// Crossing the server's 45s heartbeat threshold, with no page reload.
await expect
.poll(async () => page.getByTestId("display-status").getAttribute("data-online"), {
timeout: 120_000,
})
.toBe("false");
await expect(page.getByTestId("display-status")).toContainText("Display Offline");
});
test("an empty playlist explains itself and unplayable slides are distinguished", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const empty = await freshPlaylist(request, "Empty", []);
await openDashboardOn(page, empty.name);
await expect(page.getByTestId("empty-playlist")).toBeVisible();
await expect(page.getByTestId("empty-playlist")).toContainText("no slides yet");
// A playlist with slides that are all currently unplayable is a different
// problem and says so.
const disabled = await freshPlaylist(request, "All disabled", [library["wide-a"].id]);
await api(request, `/api/playlists/${disabled.id}/entries/${disabled.entries[0].id}`, {
method: "PATCH",
data: { enabled: false },
});
await openDashboardOn(page, disabled.name);
await expect(page.getByTestId("empty-playlist")).toHaveCount(0);
await expect(page.getByTestId("playable-summary")).toContainText("No slides are playable");
});
test("management requires a desktop while the signage route stays available", async ({
browser,
}) => {
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await context.newPage();
await page.goto(`${BASE}/`);
await expect(page.getByTestId("mobile-splash")).toBeVisible();
await expect(page.getByTestId("tile-grid")).toHaveCount(0);
// The kiosk output must never be gated by the management restriction.
await page.goto(`${BASE}/display`);
await expect(page.getByTestId("display-root")).toBeVisible();
await expect(page.getByTestId("mobile-splash")).toHaveCount(0);
await context.close();
});
+224
View File
@@ -0,0 +1,224 @@
import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import {
activatePlaylist,
api,
displayDiagnostics,
resetPlayback,
visibleScene,
waitForFirstSlide,
} from "./helpers.js";
// The outgoing layer deliberately stays opaque during and after a transition,
// so the layer actually on screen is the opaque one with the highest stacking
// order — never simply the first opaque layer in DOM order.
function readTopLayer(page, extract) {
return page.evaluate((source) => {
const layer = [...document.querySelectorAll(".scene")]
.map((element) => ({ element, style: getComputedStyle(element) }))
.filter((entry) => Number(entry.style.opacity) > 0.99)
.sort(
(left, right) =>
Number(right.style.zIndex || 0) - Number(left.style.zIndex || 0),
)[0]?.element;
if (!layer) return null;
return new Function("layer", `return (${source})(layer);`)(layer);
}, extract.toString());
}
test.beforeEach(async ({ request }) => {
await ensureFixtures(request);
await resetPlayback(request);
});
test("the display loads, connects, and shows the authoritative slide", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const { entries } = await activatePlaylist(request, "Basic", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const playback = await api(request, "/api/playback");
const diagnostics = await displayDiagnostics(page);
expect(diagnostics.renderedEntryId).toBe(playback.currentEntryId);
expect(diagnostics.renderedEntryId).toBe(entries[0].id);
expect(diagnostics.renderedAssetId).toBe(library["wide-a"].id);
expect(diagnostics.acceptedRevision).toBe(playback.playbackRevision);
// Signage output only: no dashboard chrome may appear over the slides.
await expect(page.locator(".placeholder")).toHaveCount(0);
await expect(page.locator("h1")).toHaveCount(0);
await expect(page.getByTestId("display-root")).toBeVisible();
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "false");
expect(await page.evaluate(() => document.body.innerText.trim())).toBe("");
});
test("a true 16:9 slide fills the frame with no blurred duplicate", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Wide", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const scene = await visibleScene(page);
expect(scene.fit).toBe("fill");
expect(scene.backgroundMode).toBe("none");
expect(scene.shadow).toBe("false");
const painted = await readTopLayer(page, (layer) => {
const background = layer.querySelector(".scene-background-image");
const foreground = layer.querySelector(".scene-foreground");
return {
backgroundDisplay: getComputedStyle(background).display,
foregroundFit: getComputedStyle(foreground).objectFit,
foregroundFilter: getComputedStyle(foreground).filter,
foregroundBox: foreground.getBoundingClientRect().toJSON(),
viewport: { width: window.innerWidth, height: window.innerHeight },
};
});
expect(painted.backgroundDisplay).toBe("none");
expect(painted.foregroundFit).toBe("cover");
expect(painted.foregroundFilter).toBe("none");
expect(painted.foregroundBox.width).toBeCloseTo(painted.viewport.width, 0);
expect(painted.foregroundBox.height).toBeCloseTo(painted.viewport.height, 0);
});
for (const fixture of ["portrait", "square"]) {
test(`a ${fixture} slide is contained over a blurred background`, async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, `Blurred ${fixture}`, [
{
assetId: library[fixture].id,
durationOverrideMs: 60_000,
backgroundModeOverride: "blurred",
},
]);
await page.goto("/display");
await waitForFirstSlide(page);
const scene = await visibleScene(page);
expect(scene.fit).toBe("contain");
expect(scene.backgroundMode).toBe("blurred");
expect(scene.shadow).toBe("true");
const painted = await readTopLayer(page, (layer) => {
const background = layer.querySelector(".scene-background-image");
const foreground = layer.querySelector(".scene-foreground");
const backgroundStyle = getComputedStyle(background);
return {
backgroundDisplay: backgroundStyle.display,
backgroundFit: backgroundStyle.objectFit,
backgroundFilter: backgroundStyle.filter,
backgroundTransform: backgroundStyle.transform,
backgroundBox: background.getBoundingClientRect().toJSON(),
foregroundFit: getComputedStyle(foreground).objectFit,
foregroundFilter: getComputedStyle(foreground).filter,
foregroundBox: foreground.getBoundingClientRect().toJSON(),
viewport: { width: window.innerWidth, height: window.innerHeight },
};
});
expect(painted.backgroundDisplay).toBe("block");
expect(painted.backgroundFit).toBe("cover");
expect(painted.backgroundFilter).toBe("blur(20px)");
expect(painted.backgroundTransform).not.toBe("none");
expect(painted.backgroundBox.width).toBeGreaterThanOrEqual(painted.viewport.width);
expect(painted.backgroundBox.height).toBeGreaterThanOrEqual(painted.viewport.height);
expect(painted.foregroundFit).toBe("contain");
expect(painted.foregroundFilter).toContain("drop-shadow");
// Contain must never crop: the whole slide fits inside the viewport.
expect(painted.foregroundBox.width).toBeLessThanOrEqual(painted.viewport.width + 1);
expect(painted.foregroundBox.height).toBeLessThanOrEqual(painted.viewport.height + 1);
});
}
test("solid, black, and darkened backgrounds use the effective server values", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const { playlist, entries } = await activatePlaylist(request, "Backgrounds", [
{
assetId: library.portrait.id,
durationOverrideMs: 60_000,
backgroundModeOverride: "solid",
backgroundColorOverride: "#123456",
backgroundDarkenedOverride: false,
},
]);
await page.goto("/display");
await waitForFirstSlide(page);
const readBackground = () =>
readTopLayer(page, (layer) => ({
mode: layer.dataset.backgroundMode,
darkened: layer.dataset.darkened,
color: getComputedStyle(layer.querySelector(".scene-background")).backgroundColor,
darkenOpacity: Number(
getComputedStyle(layer.querySelector(".scene-darken")).opacity,
),
foregroundOpacity: Number(
getComputedStyle(layer.querySelector(".scene-foreground")).opacity,
),
}));
await expect.poll(async () => (await readBackground()).mode).toBe("solid");
let background = await readBackground();
expect(background.color).toBe("rgb(18, 52, 86)");
expect(background.darkened).toBe("false");
expect(background.darkenOpacity).toBe(0);
// Darkening is boolean and applies to the background only.
await api(request, `/api/playlists/${playlist.id}/entries/${entries[0].id}`, {
method: "PATCH",
data: { backgroundDarkenedOverride: true },
});
await expect.poll(async () => (await readBackground()).darkened).toBe("true");
background = await readBackground();
expect(background.darkenOpacity).toBeCloseTo(0.25, 2);
expect(background.foregroundOpacity, "the readable slide is never darkened").toBe(1);
await api(request, `/api/playlists/${playlist.id}/entries/${entries[0].id}`, {
method: "PATCH",
data: { backgroundModeOverride: "black" },
});
await expect.poll(async () => (await readBackground()).mode).toBe("black");
expect((await readBackground()).color).toBe("rgb(0, 0, 0)");
});
test("the browser never receives private storage details", async ({ page, request }) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Privacy", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
const urls = [];
page.on("request", (item) => urls.push(item.url()));
await page.goto("/display");
await waitForFirstSlide(page);
const snapshot = await api(request, "/api/playback");
const serialized = JSON.stringify(snapshot) + urls.join(" ");
expect(serialized).not.toMatch(
/seaweed|8333|private\/|object_key|objectKey|bucket|secret|accessKey/i,
);
for (const url of urls.filter((item) => item.includes("/media/"))) {
expect(url).toMatch(/\/media\/assets\/[0-9a-f-]{36}\/display\/[0-9a-f]{64}\.webp$/);
}
});
+291
View File
@@ -0,0 +1,291 @@
import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import { activatePlaylist, resetPlayback } from "./helpers.js";
import {
BASE_URL,
cachedMedia,
diagnostics,
launchKiosk,
opaqueLayerCount,
waitForCachedAssets,
waitForSlide,
} from "./offline-helpers.js";
import { CONTAINERS, start, stop, waitUntilHealthy, waitUntilUnreachable } from "./stack.js";
test.describe.configure({ mode: "serial" });
test.use({ baseURL: BASE_URL });
async function restoreStack() {
await start(CONTAINERS.storage, CONTAINERS.api, CONTAINERS.web);
await waitUntilHealthy(BASE_URL);
}
test.afterAll(async () => {
await restoreStack();
});
test("the app shell is served from the worker when the origin returns a gateway error", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Gateway", [
{ assetId: library["wide-a"].id, durationOverrideMs: 5_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
await expect
.poll(async () =>
page.evaluate(async () =>
Boolean((await navigator.serviceWorker.getRegistration("/"))?.active),
),
)
.toBe(true);
await context.close();
// A remote kiosk reaches the origin through a reverse proxy, so losing the
// application behind it produces 5xx rather than a refused connection.
await stop(CONTAINERS.web);
await waitUntilUnreachable(BASE_URL);
const restarted = await launchKiosk();
try {
const response = await restarted.page.goto(`${BASE_URL}/display`, {
waitUntil: "domcontentloaded",
});
expect(
response?.status(),
"a proxied 5xx outage must still yield the cached shell",
).toBe(200);
await waitForSlide(restarted.page);
expect((await diagnostics(restarted.page)).offlineActive).toBe(true);
} finally {
await restarted.context.close();
}
} finally {
await restoreStack();
}
});
test("a damaged cached slide is skipped offline without stalling rotation", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Damaged", [
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-d"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 3_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
await waitForCachedAssets(page, [
library["wide-a"].id,
library["wide-d"].id,
library["wide-c"].id,
]);
await stop(CONTAINERS.api, CONTAINERS.storage);
await waitUntilUnreachable(BASE_URL);
// Replace one cached slide's bytes with something undecodable, then reload
// so the damage is discovered while completely offline.
await page.evaluate(async (assetId) => {
const cache = await caches.open("announcements-display-assets-v1");
for (const request of await cache.keys()) {
if (!request.url.includes(assetId)) continue;
await cache.put(
request,
new Response(new Blob([new Uint8Array([0, 1, 2, 3, 4])], { type: "image/webp" }), {
status: 200,
}),
);
}
}, library["wide-d"].id);
await page.reload({ waitUntil: "domcontentloaded" });
await waitForSlide(page);
const seen = new Set();
const deadline = Date.now() + 20_000;
while (Date.now() < deadline) {
const state = await page.evaluate(() => ({
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
opaque: [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
).length,
}));
expect(state.opaque, "the last good frame is never dropped").toBeGreaterThanOrEqual(1);
if (state.rendered) seen.add(state.rendered);
await page.waitForTimeout(200);
}
expect(seen.has(library["wide-d"].id), "the damaged slide never displays").toBe(false);
expect(seen.size, "the other cached slides keep rotating").toBeGreaterThanOrEqual(2);
const state = await diagnostics(page);
expect(state.unavailableEntryIds.length).toBeGreaterThanOrEqual(1);
expect(state.mostRecentError).not.toBeNull();
} finally {
await context.close();
await restoreStack();
}
});
test("a missing media cache offline fails safe instead of fabricating state", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Empty cache", [
{ assetId: library["wide-a"].id, durationOverrideMs: 5_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
const clientId = await page.evaluate(() => window.__display.clientId);
await stop(CONTAINERS.api, CONTAINERS.storage, CONTAINERS.web);
await waitUntilUnreachable(BASE_URL);
// Shell and IndexedDB survive; only the slide bytes are gone.
await page.evaluate(() => caches.delete("announcements-display-assets-v1"));
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForTimeout(6_000);
// No crash, no invented content, and the identity is intact.
expect(await page.evaluate(() => Boolean(window.__display))).toBe(true);
expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId);
const state = await diagnostics(page);
expect(state.renderedAssetId, "nothing is fabricated").toBeNull();
expect(state.mostRecentError, "the condition is recorded for later reporting").not.toBeNull();
// Once the infrastructure returns, the cache is rebuilt with no intervention.
await restoreStack();
await expect
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 })
.not.toBeNull();
await expect.poll(async () => (await cachedMedia(page)).length).toBeGreaterThanOrEqual(1);
} finally {
await context.close();
await restoreStack();
}
});
test("corrupt persisted state offline fails safe and recovers when the server returns", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Corrupt", [
{ assetId: library["wide-a"].id, durationOverrideMs: 5_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
const clientId = await page.evaluate(() => window.__display.clientId);
await page.evaluate(async () => {
const database = await new Promise((resolve) => {
const open = indexedDB.open("announcements-display", 1);
open.onsuccess = () => resolve(open.result);
});
await new Promise((resolve) => {
const transaction = database.transaction("state", "readwrite");
transaction
.objectStore("state")
.put({ schemaVersion: 99, manifest: "not-an-object" }, "lastKnownGood");
transaction.oncomplete = resolve;
});
database.close();
});
await stop(CONTAINERS.api, CONTAINERS.storage);
await waitUntilUnreachable(BASE_URL);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForTimeout(5_000);
// An unreadable record is discarded rather than trusted; the display stays
// alive and keeps its identity.
expect(await page.evaluate(() => Boolean(window.__display))).toBe(true);
expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId);
await restoreStack();
await expect
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 })
.toBe(library["wide-a"].id);
} finally {
await context.close();
await restoreStack();
}
});
test("a worker update keeps the separate slide media cache intact", async ({ request }) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Worker update", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
await expect
.poll(async () =>
page.evaluate(async () =>
Boolean((await navigator.serviceWorker.getRegistration("/"))?.active),
),
)
.toBe(true);
const mediaBefore = await cachedMedia(page);
expect(mediaBefore.length).toBeGreaterThanOrEqual(1);
// Simulate a stale shell generation from an older application version.
await page.evaluate(async () => {
const stale = await caches.open("announcements-display-shell-v0");
await stale.put("/display", new Response("<html>old</html>", { status: 200 }));
});
// Cleanup runs when a worker activates, so force a real install/activate
// cycle rather than calling update() on an unchanged worker file.
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration("/");
await registration.unregister();
});
await page.reload({ waitUntil: "domcontentloaded" });
await waitForSlide(page);
await expect
.poll(
async () =>
page.evaluate(async () =>
Boolean((await navigator.serviceWorker.getRegistration("/"))?.active),
),
{ timeout: 45_000 },
)
.toBe(true);
const cacheNames = await page.evaluate(() => caches.keys());
expect(
cacheNames.includes("announcements-display-shell-v0"),
"superseded shell generations are cleaned up",
).toBe(false);
expect(cacheNames).toContain("announcements-display-assets-v1");
const mediaAfter = await cachedMedia(page);
expect(
mediaAfter,
"a worker update must never delete the explicit slide media cache",
).toEqual(expect.arrayContaining(mediaBefore));
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
} finally {
await context.close();
await restoreStack();
}
});
+363
View File
@@ -0,0 +1,363 @@
import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import { activatePlaylist, api, resetPlayback } from "./helpers.js";
import {
BASE_URL,
cachedMedia,
diagnostics,
launchKiosk,
opaqueLayerCount,
waitForCachedAssets,
waitForSlide,
} from "./offline-helpers.js";
import { CONTAINERS, start, stop, waitUntilHealthy, waitUntilUnreachable } from "./stack.js";
// These scenarios remove real infrastructure, so they must not interleave.
test.describe.configure({ mode: "serial" });
test.use({ baseURL: BASE_URL });
async function restoreStack() {
await start(CONTAINERS.storage, CONTAINERS.api, CONTAINERS.web);
await waitUntilHealthy(BASE_URL);
}
test.afterAll(async () => {
await restoreStack();
});
test("cached rotation continues through a complete server outage", async ({ request }) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Outage", [
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 9_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 4_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
// Every slide of this playlist must be cached before the outage begins.
await waitForCachedAssets(page, [
library["wide-a"].id,
library["wide-b"].id,
library["wide-c"].id,
]);
await stop(CONTAINERS.api, CONTAINERS.storage);
await waitUntilUnreachable(BASE_URL);
// Sample continuously across multiple full cycles with nothing serving.
const seen = new Set();
const starts = new Map();
const deadline = Date.now() + 40_000;
while (Date.now() < deadline) {
const state = await page.evaluate(() => ({
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
offline: window.__display?.diagnostics()?.offlineActive ?? false,
opaque: [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
).length,
gate: document.querySelector(".output-gate").dataset.active,
}));
// The original black-frame bug must remain impossible offline too.
expect(state.opaque, "a good frame is always on screen offline").toBeGreaterThanOrEqual(1);
expect(state.gate).toBe("false");
if (state.rendered) {
seen.add(state.rendered);
if (!starts.has(state.rendered)) starts.set(state.rendered, Date.now());
}
await page.waitForTimeout(150);
}
expect(await diagnostics(page).then((d) => d.offlineActive)).toBe(true);
expect(seen.size, "mixed-duration rotation kept cycling with no server").toBe(3);
expect(await page.evaluate(() => window.__loadCount ?? 0)).toBe(0);
await restoreStack();
// The server is authoritative again the moment it speaks.
await expect
.poll(async () => (await diagnostics(page))?.offlineActive, { timeout: 60_000 })
.toBe(false);
const playback = await api(request, "/api/playback");
await expect
.poll(async () => (await diagnostics(page))?.acceptedRevision, { timeout: 30_000 })
.toBeGreaterThanOrEqual(playback.playbackRevision);
} finally {
await context.close();
await restoreStack();
}
});
test("a cold Chromium restart with the whole stack down restores signage", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Cold start", [
{ assetId: library["wide-a"].id, durationOverrideMs: 4_000 },
{ assetId: library["wide-d"].id, durationOverrideMs: 6_000 },
]);
// A. Warm the persistent profile while everything is healthy.
let clientId;
let cachedBefore;
{
const { context, page } = await launchKiosk();
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
await expect
.poll(async () =>
page.evaluate(async () =>
Boolean((await navigator.serviceWorker.getRegistration("/"))?.active),
),
)
.toBe(true);
await expect.poll(async () => (await cachedMedia(page)).length).toBeGreaterThanOrEqual(2);
clientId = await page.evaluate(() => window.__display.clientId);
cachedBefore = await cachedMedia(page);
await context.close();
}
// B. Remove the entire origin: no web server, no API, no object storage.
await stop(CONTAINERS.web, CONTAINERS.api, CONTAINERS.storage);
await waitUntilUnreachable(BASE_URL);
try {
// C. Relaunch the kiosk against the same URL with the same profile.
const { context, page } = await launchKiosk();
try {
const response = await page.goto(`${BASE_URL}/display`, { waitUntil: "domcontentloaded" });
expect(
response?.status(),
"the Service Worker served the app shell with the origin down",
).toBe(200);
await waitForSlide(page);
const offline = await diagnostics(page);
expect(offline.offlineActive, "the display took local authority").toBe(true);
expect(
await page.evaluate(() => window.__display.clientId),
"the persistent display identity survived the restart",
).toBe(clientId);
expect(cachedBefore).toContain(
(await cachedMedia(page)).find((path) => path.includes(library["wide-a"].id)),
);
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
// D. It must keep advancing, not merely show one restored frame.
const first = offline.renderedAssetId;
await expect
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.not.toBe(first);
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
// E. Restore the stack and confirm it surrenders authority cleanly.
await restoreStack();
await expect
.poll(async () => (await diagnostics(page))?.offlineActive, { timeout: 90_000 })
.toBe(false);
const playback = await api(request, "/api/playback");
await expect
.poll(async () => (await diagnostics(page))?.renderedEntryId, { timeout: 45_000 })
.toBe(playback.currentEntryId);
const displays = await api(request, "/api/displays");
expect(
displays.displays.filter((display) => display.clientId === clientId).length,
"one physical display stays one record across a cold restart",
).toBe(1);
} finally {
await context.close();
}
} finally {
await restoreStack();
}
});
test("an object storage outage never disturbs already cached slides", async ({ request }) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Storage outage", [
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 3_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
await waitForCachedAssets(page, [library["wide-a"].id, library["wide-b"].id]);
// The API stays up; only the bytes become unreachable.
await stop(CONTAINERS.storage);
const seen = new Set();
const deadline = Date.now() + 14_000;
while (Date.now() < deadline) {
const state = await page.evaluate(() => ({
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
opaque: [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
).length,
}));
expect(state.opaque).toBeGreaterThanOrEqual(1);
if (state.rendered) seen.add(state.rendered);
await page.waitForTimeout(200);
}
expect(seen.size, "cached slides keep rotating without object storage").toBe(2);
// A brand new asset cannot be fetched; the last good frame must survive.
const held = await diagnostics(page);
await activatePlaylist(request, "Uncached", [
{ assetId: library.standalone.id, durationOverrideMs: 60_000 },
]);
await page.waitForTimeout(6_000);
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
const during = await diagnostics(page);
expect(during.renderedAssetId, "the last good frame is retained").toBe(held.renderedAssetId);
// Nothing in the newly active playlist can be rendered locally, which is a
// more severe condition than one failed asset and is reported as such.
expect(during.mostRecentError?.kind).toBe("manifest_invalid");
// That condition reaches the server as a critical incident, exactly once.
const clientId = await page.evaluate(() => window.__display.clientId);
await page.evaluate(() => window.__display.heartbeat());
await expect
.poll(
async () => {
const displays = await api(request, "/api/displays");
return (
displays.displays.find((display) => display.clientId === clientId)?.mostRecentError
?.kind ?? null
);
},
{ timeout: 30_000 },
)
.toBe("manifest_invalid");
// Restoring storage recovers without a page reload.
await start(CONTAINERS.storage);
await waitUntilHealthy(BASE_URL);
await expect
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 })
.toBe(library.standalone.id);
expect(await page.evaluate(() => window.__loadCount ?? 0)).toBe(0);
} finally {
await context.close();
await restoreStack();
}
});
test("an API outage keeps local playback running and resynchronizes on return", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "API outage", [
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 3_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
await waitForCachedAssets(page, [
library["wide-a"].id,
library["wide-b"].id,
library["wide-c"].id,
]);
await stop(CONTAINERS.api);
await expect
.poll(async () => (await diagnostics(page))?.offlineActive, { timeout: 45_000 })
.toBe(true);
const seen = new Set();
const deadline = Date.now() + 14_000;
while (Date.now() < deadline) {
const state = await diagnostics(page);
if (state?.renderedAssetId) seen.add(state.renderedAssetId);
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
await page.waitForTimeout(250);
}
expect(seen.size).toBeGreaterThanOrEqual(2);
// While the display was blind, the operator changed authoritative state.
await start(CONTAINERS.api);
await waitUntilHealthy(BASE_URL);
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "off" } });
// Server wins immediately on reconnect, including the visual gate.
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "true", {
timeout: 60_000,
});
expect((await diagnostics(page)).offlineActive).toBe(false);
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "false", {
timeout: 30_000,
});
const playback = await api(request, "/api/playback");
await expect
.poll(async () => (await diagnostics(page))?.renderedEntryId, { timeout: 30_000 })
.toBe(playback.currentEntryId);
expect(await page.evaluate(() => window.__loadCount ?? 0)).toBe(0);
} finally {
await context.close();
await restoreStack();
}
});
test("a playlist changed during the outage is adopted cleanly on reconnect", async ({
request,
}) => {
const library = await ensureFixtures(request);
await resetPlayback(request);
await activatePlaylist(request, "Before outage", [
{ assetId: library["wide-a"].id, durationOverrideMs: 5_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 5_000 },
]);
const { context, page } = await launchKiosk();
try {
await page.goto(`${BASE_URL}/display`);
await waitForSlide(page);
const before = await diagnostics(page);
await stop(CONTAINERS.api);
await expect
.poll(async () => (await diagnostics(page))?.offlineActive, { timeout: 45_000 })
.toBe(true);
await page.waitForTimeout(6_000);
await start(CONTAINERS.api);
await waitUntilHealthy(BASE_URL);
// A different playlist entirely, activated while the display was offline.
await activatePlaylist(request, "After outage", [
{ assetId: library.square.id, durationOverrideMs: 60_000 },
]);
await expect
.poll(async () => (await diagnostics(page))?.renderedAssetId, { timeout: 90_000 })
.toBe(library.square.id);
const after = await diagnostics(page);
expect(after.offlineActive).toBe(false);
expect(after.renderedAssetId).not.toBe(before.renderedAssetId);
expect(await opaqueLayerCount(page)).toBeGreaterThanOrEqual(1);
// The superseded playlist's bytes are pruned once the new one is promoted.
await expect
.poll(async () => (await cachedMedia(page)).some((path) => path.includes(library.square.id)))
.toBe(true);
const cached = await cachedMedia(page);
expect(cached.length, "repeated playlist changes must not grow the cache forever")
.toBeLessThanOrEqual(6);
} finally {
await context.close();
await restoreStack();
}
});
+296
View File
@@ -0,0 +1,296 @@
import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import {
activatePlaylist,
api,
displayDiagnostics,
resetPlayback,
waitForFirstSlide,
} from "./helpers.js";
const ASSET_CACHE = "announcements-display-assets-v1";
test.beforeEach(async ({ request }) => {
await ensureFixtures(request);
await resetPlayback(request);
});
function cachedPaths(page, cacheName = ASSET_CACHE) {
return page.evaluate(async (name) => {
if (!(await caches.has(name))) return [];
const cache = await caches.open(name);
return (await cache.keys()).map((request) => new URL(request.url).pathname);
}, cacheName);
}
test("slide bytes are stored in the explicit cache and reused on the next load", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Cache", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
await expect.poll(async () => (await cachedPaths(page)).length).toBeGreaterThanOrEqual(2);
const stored = await cachedPaths(page);
expect(stored.some((path) => path.includes(library["wide-a"].id))).toBe(true);
expect(stored.every((path) => /^\/media\/assets\//.test(path))).toBe(true);
// Second visit must be served from the explicit cache, not the network.
const mediaRequests = [];
await page.reload();
page.on("request", (item) => {
if (item.url().includes("/media/")) mediaRequests.push(item.url());
});
await waitForFirstSlide(page);
await page.waitForTimeout(2_000);
expect(
mediaRequests.filter((url) => url.includes(library["wide-a"].id)).length,
"a cached asset must not be refetched",
).toBe(0);
const diagnostics = await displayDiagnostics(page);
expect(diagnostics.activeObjectUrls).toBeLessThanOrEqual(4);
// The heartbeat reports the true cached count.
const beat = await page.evaluate(() => window.__display.heartbeat());
expect(beat.cachedAssetCount).toBe((await cachedPaths(page)).length);
});
test("a new immutable version is fetched separately and stale bytes are pruned", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Immutable", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const first = await cachedPaths(page);
const firstUrl = first.find((path) => path.includes(library["wide-a"].id));
expect(firstUrl).toMatch(/\/display\/[0-9a-f]{64}\.webp$/);
// Switching playlists retains only what the promoted manifest still needs.
await activatePlaylist(request, "Immutable next", [
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
]);
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 45_000 })
.toBe(library["wide-c"].id);
await expect
.poll(async () => (await cachedPaths(page)).some((path) => path.includes(library["wide-c"].id)))
.toBe(true);
const retained = await cachedPaths(page);
expect(
retained.some((path) => path.includes(library["wide-c"].id)),
"the active slide is never pruned",
).toBe(true);
expect(retained.length).toBeLessThanOrEqual(first.length + 2);
});
test("the persistent client identity and last known good state survive a refresh", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Persistence", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const firstClientId = await page.evaluate(() => window.__display.clientId);
expect(firstClientId).toMatch(/[0-9a-f-]{8,}/);
await page.reload();
await waitForFirstSlide(page);
expect(await page.evaluate(() => window.__display.clientId)).toBe(firstClientId);
// A brand new page in the same origin is the same display, not a new one.
const other = await page.context().newPage();
await other.goto("/display");
await waitForFirstSlide(other);
expect(await other.evaluate(() => window.__display.clientId)).toBe(firstClientId);
await other.close();
const persisted = await page.evaluate(async () => {
const database = await new Promise((resolve, reject) => {
const open = indexedDB.open("announcements-display", 1);
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
const value = await new Promise((resolve, reject) => {
const request = database
.transaction("state", "readonly")
.objectStore("state")
.get("lastKnownGood");
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
database.close();
return value;
});
// The record schema is versioned independently of the database version.
expect(persisted.schemaVersion).toBe(2);
expect(persisted.playbackRevision).toBeGreaterThan(0);
expect(persisted.manifest.entries.length).toBeGreaterThan(0);
expect(persisted.timing.serverTime).toBeGreaterThan(0);
// The clock anchor a cold offline start depends on.
expect(persisted.timing.recordedAt).toBeGreaterThan(0);
expect(persisted.mode).toBe("on");
});
test("malformed persisted state fails safe instead of breaking startup", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Malformed", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const clientId = await page.evaluate(() => window.__display.clientId);
await page.evaluate(async () => {
const database = await new Promise((resolve) => {
const open = indexedDB.open("announcements-display", 1);
open.onsuccess = () => resolve(open.result);
});
await new Promise((resolve) => {
const transaction = database.transaction("state", "readwrite");
transaction.objectStore("state").put({ nonsense: true }, "lastKnownGood");
transaction.oncomplete = resolve;
});
database.close();
});
await page.reload();
await waitForFirstSlide(page);
// A corrupt record is discarded, the identity is kept, and the display works.
expect(await page.evaluate(() => window.__display.clientId)).toBe(clientId);
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
});
test("the service worker caches the app shell without touching slide bytes", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Worker", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
await expect
.poll(
async () =>
page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration("/");
return Boolean(registration?.active);
}),
{ timeout: 45_000 },
)
.toBe(true);
await page.reload();
await waitForFirstSlide(page);
await expect
.poll(async () => page.evaluate(() => Boolean(navigator.serviceWorker.controller)))
.toBe(true);
const shell = await cachedPaths(page, "announcements-display-shell-v1");
expect(shell.length).toBeGreaterThanOrEqual(1);
expect(shell.some((path) => path === "/display" || path.startsWith("/assets/"))).toBe(true);
// The two caches stay strictly separate.
expect(shell.some((path) => path.startsWith("/media/"))).toBe(false);
const media = await cachedPaths(page);
expect(media.every((path) => path.startsWith("/media/"))).toBe(true);
// An online kiosk still receives the newest application on refresh.
const response = await page.reload();
expect(response.status()).toBe(200);
await waitForFirstSlide(page);
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
});
test("the real display reports accurate telemetry to the server", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const { playlist } = await activatePlaylist(request, "Heartbeat", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const clientId = await page.evaluate(() => window.__display.clientId);
await page.evaluate(() => window.__display.heartbeat());
const playback = await api(request, "/api/playback");
const detail = await api(request, `/api/playlists/${playlist.id}`);
await expect
.poll(
async () => {
const displays = await api(request, "/api/displays");
return displays.displays.find((display) => display.clientId === clientId) ?? null;
},
{ timeout: 30_000 },
)
.not.toBeNull();
const displays = await api(request, "/api/displays");
const mine = displays.displays.find((display) => display.clientId === clientId);
expect(mine.status).toBe("online");
expect(mine.outputId).toBe("global");
expect(mine.activePlaylistId).toBe(playlist.id);
expect(mine.playlistRevision).toBe(detail.revision);
expect(mine.playbackRevision).toBe(playback.playbackRevision);
expect(mine.currentEntryId).toBe(playback.currentEntryId);
expect(mine.currentAssetId).toBe(playback.normalTarget.assetId);
expect(mine.cachedAssetCount).toBeGreaterThanOrEqual(2);
expect(mine.playableAssetCount).toBe(2);
expect(mine.mostRecentError).toBeNull();
});
test("the persistent client id survives closing and reopening the page", async ({
context,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Identity", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
]);
const first = await context.newPage();
await first.goto("/display");
await waitForFirstSlide(first);
const clientId = await first.evaluate(() => window.__display.clientId);
await first.close();
const second = await context.newPage();
await second.goto("/display");
await waitForFirstSlide(second);
expect(await second.evaluate(() => window.__display.clientId)).toBe(clientId);
const displays = await api(request, "/api/displays");
expect(
displays.displays.filter((display) => display.clientId === clientId).length,
"one physical display is one record",
).toBe(1);
await second.close();
});
+185
View File
@@ -0,0 +1,185 @@
import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import {
activatePlaylist,
api,
displayDiagnostics,
resetPlayback,
visibleScene,
waitForFirstSlide,
} from "./helpers.js";
test.beforeEach(async ({ request }) => {
await ensureFixtures(request);
await resetPlayback(request);
});
// Primary acceptance test. It fails if anyone restores the v1 architecture that
// faded the outgoing frame to transparent over a black root.
test("the outgoing slide stays fully opaque until the incoming slide can be drawn", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "No black", [
{ assetId: library["wide-a"].id, durationOverrideMs: 4_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
]);
const slowUrl = `**/media/assets/${library["wide-b"].id}/**`;
let released = false;
await page.route(slowUrl, async (route) => {
// Hold the incoming slide's bytes well past its nominal transition instant.
while (!released) await new Promise((resolve) => setTimeout(resolve, 100));
await route.continue();
});
await page.goto("/display");
await waitForFirstSlide(page);
const firstScene = await visibleScene(page);
expect(firstScene.sceneKey).toMatch(/^normal:/);
const before = await displayDiagnostics(page);
expect(before.renderedAssetId).toBe(library["wide-a"].id);
// Sample continuously across the boundary while B is deliberately stuck.
const samples = [];
const deadline = Date.now() + 9_000;
while (Date.now() < deadline) {
samples.push(
await page.evaluate(() => ({
at: Date.now(),
gate: document.querySelector(".output-gate").dataset.active,
layers: [...document.querySelectorAll(".scene")].map((layer) => ({
key: layer.dataset.sceneKey || "",
opacity: Number(getComputedStyle(layer).opacity),
})),
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
})),
);
await page.waitForTimeout(120);
}
for (const sample of samples) {
const opaque = sample.layers.filter((layer) => layer.opacity > 0.99);
expect(
opaque.length,
`black root was exposed at ${sample.at}: ${JSON.stringify(sample.layers)}`,
).toBeGreaterThanOrEqual(1);
expect(sample.gate).toBe("false");
// The outgoing scene must never begin fading away.
expect(sample.rendered).toBe(library["wide-a"].id);
}
released = true;
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.toBe(library["wide-b"].id);
const afterScene = await visibleScene(page);
expect(afterScene.opacity).toBe(1);
// Exactly one layer is composited afterwards; the other is free for reuse.
const layerCount = await page.evaluate(
() => document.querySelectorAll(".scene").length,
);
expect(layerCount, "scene layers are persistent, never recreated").toBe(2);
});
test("a late asset load never replaces a newer authoritative target", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Stale", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
]);
let releaseB = false;
await page.route(`**/media/assets/${library["wide-b"].id}/**`, async (route) => {
while (!releaseB) await new Promise((resolve) => setTimeout(resolve, 100));
await route.continue();
});
await page.goto("/display");
await waitForFirstSlide(page);
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library["wide-a"].id);
// Advance to B, whose bytes are stuck, then advance again to C.
await api(request, "/api/playback/next", { method: "POST" });
await page.waitForTimeout(1_500);
await api(request, "/api/playback/next", { method: "POST" });
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.toBe(library["wide-c"].id);
// B finally arrives long after it stopped being authoritative.
releaseB = true;
await page.waitForTimeout(4_000);
const diagnostics = await displayDiagnostics(page);
expect(
diagnostics.renderedAssetId,
"a stale load must never overwrite the newer target",
).toBe(library["wide-c"].id);
});
test("one unusable asset never blanks the screen or blocks the playlist", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Bad asset", [
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-d"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 3_000 },
]);
let attempts = 0;
await page.route(`**/media/assets/${library["wide-d"].id}/**`, async (route) => {
attempts += 1;
await route.fulfill({ status: 500, body: "broken" });
});
await page.goto("/display");
await waitForFirstSlide(page);
// Rotation continues across the broken slide without ever losing a frame.
const seen = new Set();
const deadline = Date.now() + 16_000;
while (Date.now() < deadline) {
const state = await page.evaluate(() => ({
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
opaque: [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
).length,
}));
if (state.rendered) seen.add(state.rendered);
expect(state.opaque, "a good frame is always on screen").toBeGreaterThanOrEqual(1);
await page.waitForTimeout(200);
}
expect(seen.has(library["wide-a"].id)).toBe(true);
expect(seen.has(library["wide-c"].id)).toBe(true);
expect(seen.has(library["wide-d"].id), "the broken slide never displays").toBe(false);
expect(attempts, "a bad asset must not be retried in a tight loop").toBeLessThan(25);
const diagnostics = await displayDiagnostics(page);
expect(diagnostics.unavailableEntryIds.length).toBeGreaterThanOrEqual(1);
expect(diagnostics.mostRecentError).not.toBeNull();
expect(["asset_fetch_failed", "slide_decode_failed"]).toContain(
diagnostics.mostRecentError.kind,
);
expect(diagnostics.mostRecentError.assetId).toBe(library["wide-d"].id);
// The structured error reaches the server through the real heartbeat.
const clientId = await page.evaluate(() => window.__display.clientId);
await page.evaluate(() => window.__display.heartbeat());
await expect
.poll(async () => {
const displays = await api(request, "/api/displays");
const mine = displays.displays.find((display) => display.clientId === clientId);
return mine?.mostRecentError?.kind ?? null;
}, { timeout: 30_000 })
.toBe(diagnostics.mostRecentError.kind);
});
+223
View File
@@ -0,0 +1,223 @@
import { expect, test } from "@playwright/test";
import { ensureFixtures } from "./fixtures.js";
import {
activatePlaylist,
api,
displayDiagnostics,
resetPlayback,
visibleScene,
waitForFirstSlide,
} from "./helpers.js";
test.beforeEach(async ({ request }) => {
await ensureFixtures(request);
await resetPlayback(request);
});
test("mode off gates the output to black while the renderer keeps tracking", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Mode", [
{ assetId: library["wide-a"].id, durationOverrideMs: 3_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 3_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
const first = await displayDiagnostics(page);
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "off" } });
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "true");
expect(
await page.evaluate(
() => Number(getComputedStyle(document.querySelector(".output-gate")).opacity),
),
).toBe(1);
// The hidden renderer keeps advancing underneath the gate.
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.not.toBe(first.renderedAssetId);
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "true");
const hidden = await displayDiagnostics(page);
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
await expect(page.getByTestId("output-gate")).toHaveAttribute("data-active", "false");
const revealed = await displayDiagnostics(page);
const playback = await api(request, "/api/playback");
expect(revealed.renderedEntryId).toBe(playback.currentEntryId);
expect(
[hidden.renderedAssetId, revealed.renderedAssetId].includes(playback.normalTarget.assetId),
).toBe(true);
// Revealing must not restart the playlist from the first slide.
expect(await visibleScene(page)).not.toBeNull();
});
test("a static hold renders a library-only asset and resume returns to rotation", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
// Long durations so normal rotation cannot advance underneath the
// assertions; this test is about the static hold, not about timing.
const { playlist } = await activatePlaylist(request, "Static", [
{ assetId: library["wide-a"].id, durationOverrideMs: 120_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 120_000 },
]);
const detail = await api(request, `/api/playlists/${playlist.id}`);
const inPlaylist = new Set(detail.entries.map((entry) => entry.assetId));
expect(inPlaylist.has(library.standalone.id)).toBe(false);
await page.goto("/display");
await waitForFirstSlide(page);
await api(request, "/api/playback/static", {
method: "POST",
data: { assetId: library.standalone.id },
});
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.toBe(library.standalone.id);
// The static asset was cached explicitly even though it is not in the manifest.
const cached = await page.evaluate(async () => {
const cache = await caches.open("announcements-display-assets-v1");
return (await cache.keys()).map((request) => new URL(request.url).pathname);
});
expect(cached.some((url) => url.includes(library.standalone.id))).toBe(true);
// Static holds indefinitely: the normal timeline does not advance locally.
await page.waitForTimeout(9_000);
expect((await displayDiagnostics(page)).renderedAssetId).toBe(library.standalone.id);
expect((await visibleScene(page)).sceneKey).toContain("static:");
await api(request, "/api/playback/resume", { method: "POST" });
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 30_000 })
.not.toBe(library.standalone.id);
// The display converges on whatever normal entry the server is authoritative
// about after the hold is released.
await expect
.poll(
async () => {
const [diagnostics, playback] = await Promise.all([
displayDiagnostics(page),
api(request, "/api/playback"),
]);
return diagnostics.renderedEntryId === playback.currentEntryId;
},
{ timeout: 30_000 },
)
.toBe(true);
expect((await visibleScene(page)).sceneKey).toMatch(/^normal:/);
});
test("a manifest or settings change is staged without reloading or blanking", async ({
page,
request,
}) => {
const library = await ensureFixtures(request);
const { playlist, entries } = await activatePlaylist(request, "Manifest", [
{ assetId: library.portrait.id, durationOverrideMs: 60_000, backgroundModeOverride: "blurred" },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-c"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
await page.evaluate(() => {
window.__loadCount = (window.__loadCount || 0) + 1;
});
const before = await displayDiagnostics(page);
const watcher = page.evaluate(() => {
// Fail loudly if the root black is ever exposed while edits are applied.
window.__blackFrames = 0;
window.__watch = setInterval(() => {
const opaque = [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
);
if (opaque.length === 0) window.__blackFrames += 1;
}, 60);
});
await watcher;
await api(request, `/api/playlists/${playlist.id}/entries/order`, {
method: "PUT",
data: { entryIds: [entries[2].id, entries[0].id, entries[1].id] },
});
await api(request, `/api/playlists/${playlist.id}/entries/${entries[0].id}`, {
method: "PATCH",
data: { durationOverrideMs: 30_000, backgroundModeOverride: "solid", backgroundColorOverride: "#204060" },
});
await api(request, "/api/settings", { method: "PATCH", data: { defaultBlurPx: 24 } });
await expect
.poll(async () => (await visibleScene(page))?.backgroundMode, { timeout: 30_000 })
.toBe("solid");
const after = await page.evaluate(() => ({
blackFrames: window.__blackFrames,
loadCount: window.__loadCount,
}));
expect(after.blackFrames, "presentation edits must not expose black").toBe(0);
expect(after.loadCount, "the page must not reload for a manifest change").toBe(1);
const diagnostics = await displayDiagnostics(page);
expect(diagnostics.renderedEntryId).toBe(before.renderedEntryId);
expect(diagnostics.renderedAssetId).toBe(before.renderedAssetId);
await page.evaluate(() => clearInterval(window.__watch));
});
test("a socket interruption keeps the last good scene and resynchronizes on reconnect", async ({
page,
context,
request,
}) => {
const library = await ensureFixtures(request);
await activatePlaylist(request, "Reconnect", [
{ assetId: library["wide-a"].id, durationOverrideMs: 60_000 },
{ assetId: library["wide-b"].id, durationOverrideMs: 60_000 },
]);
await page.goto("/display");
await waitForFirstSlide(page);
await page.evaluate(() => {
window.__loadCount = (window.__loadCount || 0) + 1;
});
const before = await displayDiagnostics(page);
await context.setOffline(true);
await page.waitForTimeout(4_000);
// A disconnect must never black the display or reload the page.
const during = await page.evaluate(() => ({
opaque: [...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
).length,
rendered: window.__display?.diagnostics()?.renderedAssetId ?? null,
loadCount: window.__loadCount,
}));
expect(during.opaque).toBeGreaterThanOrEqual(1);
expect(during.rendered).toBe(before.renderedAssetId);
expect(during.loadCount).toBe(1);
// The server moves on while the display cannot hear it.
await api(request, "/api/playback/next", { method: "POST" });
await context.setOffline(false);
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId, { timeout: 45_000 })
.toBe(library["wide-b"].id);
const playback = await api(request, "/api/playback");
const after = await displayDiagnostics(page);
expect(after.renderedEntryId).toBe(playback.currentEntryId);
expect(after.acceptedRevision).toBe(playback.playbackRevision);
expect(await page.evaluate(() => window.__loadCount)).toBe(1);
});
+86
View File
@@ -0,0 +1,86 @@
import zlib from "node:zlib";
function crc32(buffer) {
let table = [];
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
let crc = 0xffffffff;
for (const byte of buffer) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(typed));
return Buffer.concat([length, typed, crc]);
}
// Mostly flat images so the fixtures compress small; the tests identify slides
// by asset ID rather than by pixels.
export function makePng(width, height, [r, g, b]) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 2;
const raw = Buffer.alloc(height * (1 + width * 3));
for (let y = 0; y < height; y += 1) {
const offset = y * (1 + width * 3);
raw[offset] = 0;
for (let x = 0; x < width; x += 1) {
const p = offset + 1 + x * 3;
raw[p] = r;
raw[p + 1] = g;
raw[p + 2] = x < 64 && y < 64 ? 255 : b;
}
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
export const FIXTURES = [
{ name: "wide-a", width: 1920, height: 1080, rgb: [200, 30, 30] },
{ name: "wide-b", width: 1920, height: 1080, rgb: [30, 200, 30] },
{ name: "wide-c", width: 1280, height: 720, rgb: [30, 30, 200] },
{ name: "wide-d", width: 1920, height: 1080, rgb: [200, 200, 30] },
{ name: "portrait", width: 1080, height: 1920, rgb: [180, 60, 200] },
{ name: "square", width: 1200, height: 1200, rgb: [60, 180, 200] },
{ name: "standalone", width: 1920, height: 1080, rgb: [240, 140, 20] },
];
const API = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
// Uploads are content-addressed, so re-running the suite reuses the same assets
// instead of piling up duplicates.
export async function ensureFixtures(request) {
const existing = await (await request.fetch(`${API}/api/assets`)).json();
const byName = new Map(existing.assets.map((asset) => [asset.displayName, asset]));
for (const fixture of FIXTURES) {
if (byName.has(fixture.name)) continue;
const response = await request.fetch(`${API}/api/assets`, {
method: "POST",
multipart: {
files: {
name: `${fixture.name}.png`,
mimeType: "image/png",
buffer: makePng(fixture.width, fixture.height, fixture.rgb),
},
},
});
if (!response.ok()) {
throw new Error(`fixture upload failed: ${response.status()} ${await response.text()}`);
}
}
const refreshed = await (await request.fetch(`${API}/api/assets`)).json();
return Object.fromEntries(refreshed.assets.map((asset) => [asset.displayName, asset]));
}
+106
View File
@@ -0,0 +1,106 @@
import { expect } from "@playwright/test";
const API = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
export async function api(request, path, { method = "GET", data } = {}) {
const response = await request.fetch(`${API}${path}`, {
method,
...(data === undefined
? {}
: { headers: { "content-type": "application/json" }, data }),
});
const body = await response.json();
if (!response.ok()) {
throw new Error(`${method} ${path} -> ${response.status()} ${JSON.stringify(body)}`);
}
return body;
}
export async function assets(request) {
const list = await api(request, "/api/assets");
return Object.fromEntries(list.assets.map((asset) => [asset.displayName, asset]));
}
// Every test gets its own playlist so fixtures never bleed between cases.
export async function activatePlaylist(request, name, entries) {
const playlist = await api(request, "/api/playlists", {
method: "POST",
data: { name: `${name} ${Date.now()}-${Math.random().toString(16).slice(2, 8)}` },
});
const created = [];
for (const entry of entries) {
created.push(
await api(request, `/api/playlists/${playlist.id}/entries`, {
method: "POST",
data: entry,
}),
);
}
const activation = await api(request, `/api/playlists/${playlist.id}/activate`, {
method: "POST",
});
// Activation legitimately queues behind a slide that is still mid-duration.
// A manual next is the documented way to take that boundary immediately, so
// fixtures do not have to wait out the previous test's slide.
if (activation.activation === "pending") {
await api(request, "/api/playback/next", { method: "POST" });
}
await expect
.poll(async () => (await api(request, "/api/playback")).activePlaylistId, {
timeout: 90_000,
})
.toBe(playlist.id);
return { playlist, entries: created };
}
// Global presentation settings are shared state that individual tests
// legitimately change. Restoring the documented defaults before each test keeps
// every case deterministic no matter what ran before it.
export async function resetPlayback(request) {
await api(request, "/api/playback/mode", { method: "PUT", data: { mode: "on" } });
const state = await api(request, "/api/playback");
if (state.transportState === "static" || state.transportState === "paused") {
await api(request, "/api/playback/resume", { method: "POST" });
}
await api(request, "/api/settings", {
method: "PATCH",
data: {
defaultBlurPx: 20,
defaultBackgroundMode: "blurred",
defaultSolidBackgroundColor: "#000000",
defaultBackgroundDarkened: false,
},
});
}
export function displayDiagnostics(page) {
return page.evaluate(() => window.__display?.diagnostics() ?? null);
}
export async function waitForFirstSlide(page) {
await expect
.poll(async () => (await displayDiagnostics(page))?.renderedAssetId ?? null, {
timeout: 45_000,
})
.not.toBeNull();
}
export function visibleScene(page) {
// The scene layer that is actually composited: opacity 1 and highest z-index.
return page.evaluate(() => {
const layers = [...document.querySelectorAll(".scene")];
const painted = layers
.map((layer) => ({
layer: layer.dataset.layer,
sceneKey: layer.dataset.sceneKey || "",
fit: layer.dataset.fit || "",
backgroundMode: layer.dataset.backgroundMode || "",
darkened: layer.dataset.darkened || "",
shadow: layer.dataset.shadow || "",
opacity: Number(getComputedStyle(layer).opacity),
zIndex: Number(getComputedStyle(layer).zIndex || 0),
}))
.filter((entry) => entry.opacity > 0.99);
return painted.sort((a, b) => b.zIndex - a.zIndex)[0] || null;
});
}
+58
View File
@@ -0,0 +1,58 @@
import { chromium, expect } from "@playwright/test";
export const PROFILE_DIR = process.env.DISPLAY_PROFILE_DIR || "/profiles/kiosk";
export const BASE_URL = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
// A real kiosk keeps a persistent Chromium profile: the Service Worker,
// Cache Storage, and IndexedDB that make offline startup possible all live
// there. Incognito or an ephemeral user-data-dir would defeat the whole phase.
export async function launchKiosk() {
const context = await chromium.launchPersistentContext(PROFILE_DIR, {
viewport: { width: 1280, height: 720 },
serviceWorkers: "allow",
});
const page = context.pages()[0] || (await context.newPage());
return { context, page };
}
export function diagnostics(page) {
return page.evaluate(() => window.__display?.diagnostics() ?? null);
}
export async function waitForSlide(page, timeout = 60_000) {
await expect
.poll(async () => (await diagnostics(page))?.renderedAssetId ?? null, { timeout })
.not.toBeNull();
}
export function opaqueLayerCount(page) {
return page.evaluate(
() =>
[...document.querySelectorAll(".scene")].filter(
(layer) => Number(getComputedStyle(layer).opacity) > 0.99,
).length,
);
}
// Waiting for "some" cached assets is not enough: a scenario that removes
// storage must first know that the specific slides it needs are present.
export async function waitForCachedAssets(page, assetIds, timeout = 60_000) {
const { expect } = await import("@playwright/test");
await expect
.poll(
async () => {
const cached = await cachedMedia(page);
return assetIds.every((id) => cached.some((path) => path.includes(id)));
},
{ timeout },
)
.toBe(true);
}
export async function cachedMedia(page) {
return page.evaluate(async () => {
if (!(await caches.has("announcements-display-assets-v1"))) return [];
const cache = await caches.open("announcements-display-assets-v1");
return (await cache.keys()).map((request) => new URL(request.url).pathname);
});
}
+74
View File
@@ -0,0 +1,74 @@
import http from "node:http";
// The offline suite must genuinely remove infrastructure, not simulate it.
// Talking to the Docker Engine socket directly avoids installing a CLI into
// the browser image and keeps every scenario inside one Playwright run.
const SOCKET = "/var/run/docker.sock";
function engine(method, path) {
return new Promise((resolve, reject) => {
const request = http.request({ socketPath: SOCKET, method, path }, (response) => {
let body = "";
response.on("data", (chunk) => (body += chunk));
response.on("end", () => resolve({ status: response.statusCode, body }));
});
request.on("error", reject);
request.end();
});
}
const PROJECT = process.env.VERIFY_PROJECT || "announcements-p5b-verify";
export const CONTAINERS = {
web: `${PROJECT}-web-1`,
api: `${PROJECT}-api-1`,
storage: `${PROJECT}-seaweedfs-1`,
};
export async function stop(...names) {
for (const name of names) await engine("POST", `/containers/${name}/stop?t=2`);
}
export async function start(...names) {
for (const name of names) await engine("POST", `/containers/${name}/start`);
}
export async function isRunning(name) {
const { status, body } = await engine("GET", `/containers/${name}/json`);
if (status !== 200) return false;
return Boolean(JSON.parse(body).State?.Running);
}
export async function waitUntilHealthy(baseUrl, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(`${baseUrl}/api/playback`, {
signal: AbortSignal.timeout(2_000),
});
if (response.ok) return true;
} catch {
// Still coming up.
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error("stack did not become reachable in time");
}
// With the API stopped but Nginx still running, the proxy answers 502. That is
// a response, not a reachable API, so a gateway error counts as unreachable
// just like a refused connection does.
export async function waitUntilUnreachable(baseUrl, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(`${baseUrl}/api/playback`, {
signal: AbortSignal.timeout(1_000),
});
if (!response.ok) return true;
} catch {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error("stack stayed reachable");
}
+11 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'test-results', 'playwright-report']),
{
files: ['**/*.{js,jsx}'],
extends: [
@@ -18,4 +18,14 @@ export default defineConfig([
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
{
// Browser tests and their tooling run in Node, not in the page.
files: ['e2e/**/*.js', 'playwright.config.js', 'src/display/__tests__/**/*.js'],
languageOptions: { globals: { ...globals.node } },
},
{
// The signage app-shell worker runs in a Service Worker scope.
files: ['public/display-sw.js'],
languageOptions: { globals: { ...globals.serviceworker } },
},
])
+50
View File
@@ -0,0 +1,50 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Matches the default API allowance of twenty 25 MiB files plus multipart
# overhead. Keep this in sync if the backend upload limits are raised.
client_max_body_size 512m;
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_origin http://api:7532;
location ~ ^/(api|health|media)(/|$) {
proxy_pass $api_origin;
proxy_http_version 1.1;
proxy_connect_timeout 5s;
# Multipart requests return only after image processing and private-S3
# writes complete. This is an inactivity timeout, not a size limit.
proxy_read_timeout 10m;
proxy_send_timeout 10m;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /socket.io/ {
proxy_pass $api_origin;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 75s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+12 -2
View File
@@ -1,21 +1,31 @@
{
"name": "client",
"name": "@announcements/client",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test",
"test:unit": "node --test \"src/display/__tests__/*.test.js\""
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@fontsource/roboto": "5.2.5",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "1.62.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, devices } from "@playwright/test";
// The display is verified against the real isolated stack through Nginx, which
// is how a kiosk actually reaches it. DISPLAY_BASE_URL is supplied by the
// verification harness.
const baseURL = process.env.DISPLAY_BASE_URL || "http://127.0.0.1:5199";
export default defineConfig({
testDir: "./e2e",
// Signage timing is real wall-clock timing; these are not unit tests.
timeout: 120_000,
expect: { timeout: 20_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"]],
use: {
baseURL,
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
serviceWorkers: "allow",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
-1572
View File
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
/* Announcements signage app-shell worker.
*
* Scope is deliberately narrow: this worker makes the /display application
* shell available for a future offline start. Slide bytes are NOT its job —
* those are owned explicitly by the display asset manager in Cache Storage, so
* the two caches never compete for the same responsibility.
*/
const SHELL_CACHE = "announcements-display-shell-v1";
const SHELL_FALLBACK = "/display";
// A kiosk must reach its slides quickly. When a usable shell is already
// cached, a slow or dead origin is never allowed to delay startup: a reverse
// proxy whose backend is down can otherwise hold a navigation open for its
// full connect timeout, leaving the screen blank the whole time.
const SHELL_NETWORK_TIMEOUT_MS = 3_000;
// Paths the worker must never handle. Media is the asset manager's; the API and
// socket are live server state and must never be answered from a cache.
const BYPASS_PREFIXES = ["/api/", "/socket.io/", "/media/", "/health"];
function isBypassed(url) {
return BYPASS_PREFIXES.some(
(prefix) => url.pathname === prefix.replace(/\/$/, "") || url.pathname.startsWith(prefix),
);
}
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
try {
await cache.add(new Request(SHELL_FALLBACK, { cache: "reload" }));
} catch {
// A first install while the network is unavailable still succeeds; the
// shell is captured on the next successful navigation instead.
}
// A kiosk must never be trapped on a stale application version.
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
for (const name of await caches.keys()) {
// Only this worker's own shell generations are removed. The display
// asset cache is left strictly alone.
if (name.startsWith("announcements-display-shell-") && name !== SHELL_CACHE) {
await caches.delete(name);
}
}
await self.clients.claim();
})(),
);
});
async function networkFirst(request) {
const cache = await caches.open(SHELL_CACHE);
const cached =
(await cache.match(SHELL_FALLBACK)) || (await cache.match("/index.html"));
// Without a cached shell there is nothing to fall back to, so wait normally.
if (!cached) {
const response = await fetch(request);
if (response && response.ok) await cache.put(SHELL_FALLBACK, response.clone());
return response;
}
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), SHELL_NETWORK_TIMEOUT_MS);
try {
const response = await fetch(request, { signal: abort.signal });
clearTimeout(timer);
if (response && response.ok) {
// Keep the shell fresh so an online kiosk always updates.
await cache.put(SHELL_FALLBACK, response.clone());
return response;
}
// A production kiosk reaches this origin through a reverse proxy, so an
// outage behind it arrives as 502/503/504 rather than a refused
// connection. That is still an outage: serve the cached shell.
return cached;
} catch {
clearTimeout(timer);
return cached;
}
}
async function cacheFirst(request) {
const cache = await caches.open(SHELL_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response && response.ok) await cache.put(request, response.clone());
return response;
}
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (isBypassed(url)) return;
if (request.mode === "navigate") {
// Online kiosks always get the newest application; offline ones get the
// last known good shell.
event.respondWith(networkFirst(request));
return;
}
// Vite emits content-hashed asset filenames, so these are safely immutable.
if (url.pathname.startsWith("/assets/")) {
event.respondWith(cacheFirst(request));
}
});
-74
View File
@@ -1,74 +0,0 @@
html,
body,
#root {
background: red !important;
}
#root {
margin: 0;
width: 100%;
height: 100%;
background: black;
overflow: hidden;
}
.slideshow {
position: relative;
width: 100vw;
height: 100vh;
background: black;
}
.slide {
position: absolute;
inset: 0;
width: 100vw;
height: 100vh;
object-fit: cover !important;
}
.previous {
opacity: 1;
animation: fadeOut 1000ms ease-in-out forwards;
}
.current {
opacity: 0;
animation: fadeIn 1000ms ease-in-out forwards;
}
.empty {
width: 100vw;
height: 100vh;
background: black;
color: white;
display: grid;
place-items: center;
font-family: sans-serif;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.blank-screen {
position: fixed;
inset: 0;
background: black;
width: 100vw;
height: 100vh;
}
-67
View File
@@ -1,67 +0,0 @@
import { useEffect, useState } from "react";
import { io } from "socket.io-client";
import "./App.css";
const socket = io("http://10.40.0.2:7532");
export default function App() {
const [slides, setSlides] = useState([]);
const [currentSlide, setCurrentSlide] = useState(0);
const [previousSlide, setPreviousSlide] = useState(null);
const [mode, setMode] = useState("on");
useEffect(() => {
socket.on("slideshow:state", (state) => {
setSlides(state.slides || []);
setMode(state.mode ?? "on");
setCurrentSlide((oldSlide) => {
if (oldSlide !== state.currentSlide) {
setPreviousSlide(oldSlide);
}
return state.currentSlide;
});
setTimeout(() => {
setPreviousSlide(null);
}, 1100);
});
return () => {
socket.off("slideshow:state");
};
}, []);
if (mode === "off") {
return <div className="blank-screen" />;
}
const currentImage = slides[currentSlide];
const previousImage =
previousSlide !== null ? slides[previousSlide] : null;
if (!currentImage) {
return <div className="empty">No slides loaded</div>;
}
return (
<div className="slideshow">
{previousImage && (
<img
key={`previous-${previousImage}`}
className="slide previous"
src={previousImage}
alt=""
/>
)}
<img
key={`current-${currentImage}`}
className="slide current"
src={currentImage}
alt=""
/>
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

+522
View File
@@ -0,0 +1,522 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { io } from "socket.io-client";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import { api, isAcceptedFile, uploadAsset } from "./api.js";
import LibraryPanel from "./LibraryPanel.jsx";
import PlaylistTiles from "./PlaylistTiles.jsx";
import StatusPanel from "./StatusPanel.jsx";
import Toasts from "./Toasts.jsx";
import UploadQueue from "./UploadQueue.jsx";
import "./dashboard.css";
const DESKTOP_MIN_WIDTH = 769;
let uploadSequence = 0;
function MobileSplash() {
return (
<main className="mobile-splash" data-testid="mobile-splash">
<h1>Announcements</h1>
<p>Management needs a desktop-sized screen.</p>
<p className="muted">
Open this page on a computer to manage playlists and slides. The signage
output at <code>/display</code> works on any screen.
</p>
</main>
);
}
export default function DashboardApp() {
const [wideEnough, setWideEnough] = useState(
() => window.innerWidth >= DESKTOP_MIN_WIDTH,
);
const [playlists, setPlaylists] = useState([]);
const [playlist, setPlaylist] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [snapshot, setSnapshot] = useState(null);
const [displays, setDisplays] = useState([]);
const [assets, setAssets] = useState([]);
const [libraryOpen, setLibraryOpen] = useState(false);
const [uploads, setUploads] = useState([]);
const [toasts, setToasts] = useState([]);
const [busyEntryIds, setBusyEntryIds] = useState(() => new Set());
const [dropping, setDropping] = useState(false);
const [activating, setActivating] = useState(false);
const fileInputRef = useRef(null);
const selectedIdRef = useRef(null);
// Async loads check this to discard responses for a playlist the operator
// has already navigated away from. Refs must not be written during render.
useEffect(() => {
selectedIdRef.current = selectedId;
}, [selectedId]);
useEffect(() => {
const onResize = () => setWideEnough(window.innerWidth >= DESKTOP_MIN_WIDTH);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
const notify = useCallback((message, tone = "info") => {
const id = `toast-${++uploadSequence}`;
setToasts((current) => [...current, { id, message, tone }]);
const timer = setTimeout(
() => setToasts((current) => current.filter((toast) => toast.id !== id)),
6_000,
);
return () => clearTimeout(timer);
}, []);
const loadPlaylist = useCallback(async (playlistId) => {
if (!playlistId) {
setPlaylist(null);
return null;
}
const detail = await api.getPlaylist(playlistId);
// Only apply if the operator is still looking at this playlist.
if (selectedIdRef.current === playlistId) setPlaylist(detail);
return detail;
}, []);
const refreshPlaylists = useCallback(async () => {
const list = await api.listPlaylists();
setPlaylists(list);
return list;
}, []);
// Initial load: open directly on whatever is actually active.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [list, playback, displayList] = await Promise.all([
api.listPlaylists(),
api.getPlayback(),
api.listDisplays().catch(() => []),
]);
if (cancelled) return;
setPlaylists(list);
setSnapshot(playback);
setDisplays(displayList);
const active = list.find((item) => item.isActive) || list[0] || null;
if (active) {
setSelectedId(active.id);
selectedIdRef.current = active.id;
await loadPlaylist(active.id);
}
} catch (error) {
if (!cancelled) notify(error.message, "error");
}
})();
return () => {
cancelled = true;
};
}, [loadPlaylist, notify]);
// Socket handlers are registered before connecting, and torn down on unmount
// so a StrictMode remount cannot leave duplicates behind.
useEffect(() => {
const socket = io({ autoConnect: false });
const onPlayback = (next) => setSnapshot(next);
const onPlaylists = () => {
refreshPlaylists().catch(() => {});
if (selectedIdRef.current) loadPlaylist(selectedIdRef.current).catch(() => {});
};
const onDisplay = () => {
api.listDisplays().then(setDisplays).catch(() => {});
};
socket.on("playback:state", onPlayback);
socket.on("playlists:changed", onPlaylists);
socket.on("display:status", onDisplay);
socket.connect();
return () => {
socket.off("playback:state", onPlayback);
socket.off("playlists:changed", onPlaylists);
socket.off("display:status", onDisplay);
socket.disconnect();
};
}, [loadPlaylist, refreshPlaylists]);
// Heartbeat freshness decides online/offline, so poll the status endpoint at
// a low rate rather than trusting the last event forever.
useEffect(() => {
const timer = setInterval(() => {
api.listDisplays().then(setDisplays).catch(() => {});
}, 10_000);
return () => clearInterval(timer);
}, []);
const activePlaylistId = snapshot?.activePlaylistId ?? null;
const pendingPlaylistId = snapshot?.pendingPlaylistId ?? null;
const isActive = playlist && playlist.id === activePlaylistId;
const isPending = playlist && playlist.id === pendingPlaylistId;
async function selectPlaylist(playlistId) {
// Selecting a playlist only changes what is being edited. It never
// activates anything.
setSelectedId(playlistId);
selectedIdRef.current = playlistId;
setPlaylist(null);
try {
await loadPlaylist(playlistId);
} catch (error) {
notify(error.message, "error");
}
}
async function activateSelected() {
if (!playlist) return;
setActivating(true);
try {
const result = await api.activatePlaylist(playlist.id);
// The server decides whether this is immediate or queued to a boundary.
notify(
result.activation === "pending"
? `${playlist.name} will start when the current slide finishes`
: `${playlist.name} is now active`,
"success",
);
setSnapshot(result.snapshot ?? (await api.getPlayback()));
await refreshPlaylists();
} catch (error) {
notify(error.message, "error");
} finally {
setActivating(false);
}
}
async function createPlaylist() {
const name = `Playlist ${new Date().toLocaleDateString()}`;
try {
const created = await api.createPlaylist(name);
await refreshPlaylists();
await selectPlaylist(created.id);
notify(`Created ${created.name}`, "success");
} catch (error) {
notify(error.message, "error");
}
}
async function toggleEntry(entry) {
if (!playlist) return;
setBusyEntryIds((current) => new Set(current).add(entry.id));
try {
await api.updateEntry(playlist.id, entry.id, { enabled: !entry.enabled });
await loadPlaylist(playlist.id);
} catch (error) {
notify(error.message, "error");
await loadPlaylist(playlist.id).catch(() => {});
} finally {
setBusyEntryIds((current) => {
const next = new Set(current);
next.delete(entry.id);
return next;
});
}
}
async function reorderEntries(entryIds) {
if (!playlist) return;
try {
const detail = await api.reorderEntries(playlist.id, entryIds);
if (selectedIdRef.current === playlist.id) setPlaylist(detail);
} catch (error) {
notify(`Could not save the new order: ${error.message}`, "error");
// Restore whatever the server actually holds.
await loadPlaylist(playlist.id).catch(() => {});
throw error;
}
}
async function addAssets(assetIds) {
if (!playlist) return;
for (const assetId of assetIds) {
try {
await api.addEntry(playlist.id, assetId);
} catch (error) {
notify(
error.code === "PLAYLIST_ENTRY_EXISTS"
? "That slide is already in this playlist"
: error.message,
error.code === "PLAYLIST_ENTRY_EXISTS" ? "info" : "error",
);
}
}
await loadPlaylist(playlist.id).catch(() => {});
}
function patchUpload(id, patch) {
setUploads((current) =>
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
);
}
// Uploads run sequentially so appended entries keep the operator's original
// selection order rather than whichever request finished first.
async function uploadFiles(fileList) {
const files = [...fileList];
if (files.length === 0) return;
const accepted = files.filter(isAcceptedFile);
const rejected = files.filter((file) => !isAcceptedFile(file));
for (const file of rejected) {
const id = `upload-${++uploadSequence}`;
setUploads((current) => [
...current,
{
id,
name: file.name,
state: "failed",
progress: 0,
message: "Only PNG, JPEG, and WebP images are supported",
},
]);
}
const queued = accepted.map((file) => ({
file,
id: `upload-${++uploadSequence}`,
}));
setUploads((current) => [
...current,
...queued.map(({ file, id }) => ({
id,
name: file.name,
state: "queued",
progress: 0,
message: null,
})),
]);
for (const { file, id } of queued) {
try {
patchUpload(id, { state: "uploading" });
const result = await uploadAsset(file, {
onProgress: (progress) => patchUpload(id, { progress }),
});
patchUpload(id, { state: "processing", progress: 1 });
const record = result?.asset ? result : result?.results?.[0];
const asset = record?.asset;
if (!asset) {
patchUpload(id, { state: "failed", message: "Upload returned no asset" });
continue;
}
const duplicate = record.duplicate || record.created === false;
if (playlist) {
try {
await api.addEntry(playlist.id, asset.id);
patchUpload(id, { state: duplicate ? "duplicate" : "complete" });
} catch (error) {
if (error.code === "PLAYLIST_ENTRY_EXISTS") {
patchUpload(id, {
state: "already_in_playlist",
message: "This image is already in this playlist",
});
} else {
patchUpload(id, { state: "failed", message: error.message });
}
}
} else {
patchUpload(id, { state: duplicate ? "duplicate" : "complete" });
}
} catch (error) {
patchUpload(id, {
state: "failed",
message:
error.code === "ASSET_NAME_CONFLICT"
? "Another slide already uses that name"
: error.message,
});
}
}
await Promise.all([
loadPlaylist(selectedIdRef.current).catch(() => {}),
api.listAssets().then(setAssets).catch(() => {}),
]);
}
async function openLibrary() {
try {
setAssets(await api.listAssets());
setLibraryOpen(true);
} catch (error) {
notify(error.message, "error");
}
}
const activationState = useMemo(() => {
if (!playlist) return null;
if (isActive) return "active";
if (isPending) return "pending";
return "inactive";
}, [playlist, isActive, isPending]);
if (!wideEnough) return <MobileSplash />;
return (
<div
className="dashboard"
data-testid="dashboard"
data-dropping={dropping ? "true" : "false"}
onDragOver={(event) => {
event.preventDefault();
setDropping(true);
}}
onDragLeave={(event) => {
if (event.currentTarget === event.target) setDropping(false);
}}
onDrop={(event) => {
event.preventDefault();
setDropping(false);
if (event.dataTransfer?.files?.length) void uploadFiles(event.dataTransfer.files);
}}
>
<header className="dashboard-header">
<div className="header-identity">
<span className="product">Announcements</span>
<div className="playlist-choice">
<label className="visually-hidden" htmlFor="playlist-select">
Playlist
</label>
<select
id="playlist-select"
data-testid="playlist-select"
value={selectedId ?? ""}
onChange={(event) => selectPlaylist(event.target.value)}
>
{playlists.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
{item.id === activePlaylistId ? " (active)" : ""}
</option>
))}
</select>
<button type="button" className="button subtle" onClick={createPlaylist}>
New Playlist
</button>
</div>
</div>
<div className="header-activation" data-testid="activation-state" data-state={activationState}>
{activationState === "active" ? (
<span className="badge active">ACTIVE</span>
) : activationState === "pending" ? (
<>
<span className="badge pending">STARTING SOON</span>
<span className="muted">Waiting for the current slide to finish</span>
</>
) : activationState === "inactive" ? (
<>
<span className="badge inactive">NOT ACTIVE</span>
<button
type="button"
className="button primary"
data-testid="activate-button"
onClick={activateSelected}
disabled={activating}
>
Activate Playlist
</button>
</>
) : null}
</div>
<div className="header-actions">
<button
type="button"
className="button primary"
data-testid="upload-button"
onClick={() => fileInputRef.current?.click()}
>
Upload Slides
</button>
<button type="button" className="button" data-testid="library-button" onClick={openLibrary}>
Library
</button>
<input
ref={fileInputRef}
data-testid="file-input"
type="file"
multiple
accept="image/png,image/jpeg,image/webp"
className="visually-hidden"
onChange={(event) => {
void uploadFiles(event.target.files);
event.target.value = "";
}}
/>
</div>
</header>
<div className="dashboard-body">
<main className="playlist-area">
{playlist ? (
<>
<div className="playlist-heading">
<h1>{playlist.name}</h1>
<PlayableSummary playlist={playlist} />
</div>
<PlaylistTiles
playlist={playlist}
busyEntryIds={busyEntryIds}
onToggleEntry={toggleEntry}
onReorder={reorderEntries}
onUploadRequest={() => fileInputRef.current?.click()}
onLibraryRequest={openLibrary}
/>
</>
) : (
<div className="skeleton-grid" data-testid="loading-skeleton" aria-hidden="true">
{Array.from({ length: 6 }, (unused, index) => (
<div key={index} className="skeleton-tile" />
))}
</div>
)}
</main>
<StatusPanel snapshot={snapshot} displays={displays} />
</div>
<UploadQueue
items={uploads}
onDismiss={() =>
setUploads((current) =>
current.filter((item) => item.state === "uploading" || item.state === "queued"),
)
}
/>
{libraryOpen && playlist ? (
<LibraryPanel
assets={assets}
playlist={playlist}
busy={false}
onClose={() => setLibraryOpen(false)}
onAdd={addAssets}
/>
) : null}
<Toasts toasts={toasts} onDismiss={(id) => setToasts((c) => c.filter((t) => t.id !== id))} />
{dropping ? (
<div className="drop-overlay" data-testid="drop-overlay">
Drop images to add them to this playlist
</div>
) : null}
</div>
);
}
// An empty playlist and a playlist whose slides are all currently unplayable
// are different problems, so they are explained differently.
function PlayableSummary({ playlist }) {
const total = playlist.entries.length;
const playable = playlist.entries.filter((entry) => entry.playable).length;
if (total === 0) return null;
if (playable > 0) {
return (
<p className="muted" data-testid="playable-summary">
{playable} of {total} slides playing now
</p>
);
}
return (
<p className="warning" data-testid="playable-summary">
No slides are playable right now — they are disabled, outside their schedule, or unavailable.
</p>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useMemo, useState } from "react";
import { slideLabel } from "./api.js";
function expirationLabel(asset, now) {
// A permanent asset has no expiration and must never look stale merely
// because it is old.
if (asset.expirationAt === null || asset.expirationAt === undefined) return null;
const when = new Date(asset.expirationAt);
if (asset.expirationAt <= now) return "Expired";
return `Expires ${when.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
}
export default function LibraryPanel({ assets, playlist, onAdd, onClose, busy }) {
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState(() => new Set());
// Reading the clock during render is impure; it is sampled here instead and
// refreshed slowly, which is ample for an expiry label.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(timer);
}, []);
const inPlaylist = useMemo(
() => new Set(playlist.entries.map((entry) => entry.assetId)),
[playlist.entries],
);
const visible = useMemo(() => {
const needle = filter.trim().toLowerCase();
return assets.filter((asset) => {
if (!needle) return true;
return slideLabel(asset).toLowerCase().includes(needle);
});
}, [assets, filter]);
function toggle(asset, addable) {
if (!addable) return;
setSelected((current) => {
const next = new Set(current);
if (next.has(asset.id)) next.delete(asset.id);
else next.add(asset.id);
return next;
});
}
return (
<aside className="library-panel" data-testid="library-panel" aria-label="Library">
<header>
<h2>Library</h2>
<button type="button" className="button subtle" onClick={onClose} aria-label="Close library">
Close
</button>
</header>
<input
type="search"
className="library-search"
placeholder="Search slides"
aria-label="Search library"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<ul className="library-grid">
{visible.map((asset) => {
const already = inPlaylist.has(asset.id);
const expired = asset.expirationAt !== null && asset.expirationAt <= now;
const ready = asset.status === "ready";
const addable = !already && !expired && ready;
const expiration = expirationLabel(asset, now);
return (
<li
key={asset.id}
data-testid="library-item"
data-asset-id={asset.id}
data-addable={addable ? "true" : "false"}
data-in-playlist={already ? "true" : "false"}
data-selected={selected.has(asset.id) ? "true" : "false"}
>
<button
type="button"
onClick={() => toggle(asset, addable)}
disabled={!addable}
aria-pressed={selected.has(asset.id)}
aria-label={`${slideLabel(asset)}${already ? " — already in this playlist" : ""}`}
>
{asset.thumbnail?.url ? (
<img src={asset.thumbnail.url} alt="" loading="lazy" />
) : (
<span className="library-image-missing" aria-hidden="true" />
)}
<span className="library-name">{slideLabel(asset)}</span>
{already ? <span className="library-tag">In this playlist</span> : null}
{!ready ? <span className="library-tag warn">Unavailable</span> : null}
{expiration ? (
<span className="library-tag" data-expired={expired ? "true" : "false"}>
{expiration}
</span>
) : null}
</button>
</li>
);
})}
</ul>
<footer>
<button
type="button"
className="button primary"
disabled={selected.size === 0 || busy}
onClick={() => onAdd([...selected]).then(() => setSelected(new Set()))}
>
Add {selected.size > 0 ? `${selected.size} ` : ""}to playlist
</button>
</footer>
</aside>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { useMemo, useState } from "react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { slideLabel } from "./api.js";
import { entryStatus } from "./entryStatus.js";
function Tile({ entry, onToggle, busy }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: entry.id,
});
const status = entryStatus(entry);
const label = slideLabel(entry.asset);
return (
<li
ref={setNodeRef}
className="tile"
data-testid="tile"
data-entry-id={entry.id}
data-asset-id={entry.assetId}
data-ring={status.ring}
data-enabled={entry.enabled ? "true" : "false"}
data-dragging={isDragging ? "true" : "false"}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
<button
type="button"
className="tile-surface"
// Primary click toggles this playlist entry only. The underlying
// library asset, and its use in other playlists, is untouched.
onClick={() => onToggle(entry)}
disabled={busy}
aria-pressed={entry.enabled}
aria-label={`${label} — ${status.label}. Click to ${
entry.enabled ? "disable" : "enable"
}.`}
title={`${label} — ${status.label}`}
>
<span className="tile-ring" data-ring={status.ring} aria-hidden="true" />
{entry.asset?.thumbnail?.url ? (
<img className="tile-image" src={entry.asset.thumbnail.url} alt="" loading="lazy" />
) : (
<span className="tile-image tile-image-missing" aria-hidden="true" />
)}
<span className="tile-status" data-testid="tile-status">
{status.label}
</span>
</button>
<span
className="tile-drag"
{...attributes}
{...listeners}
data-testid="tile-drag"
role="button"
tabIndex={0}
aria-label={`Reorder ${label}`}
title="Drag to reorder"
>
<span aria-hidden="true">⠿</span>
</span>
<span className="tile-name" title={label}>
{label}
</span>
</li>
);
}
export default function PlaylistTiles({
playlist,
onToggleEntry,
onReorder,
onUploadRequest,
onLibraryRequest,
busyEntryIds,
}) {
// Local order exists only so a drag feels immediate; the server remains the
// source of truth. Authoritative entries are adopted during render rather
// than synced in an effect, so no cascading render is needed.
const authoritative = useMemo(
() => playlist.entries.map((entry) => entry.id),
[playlist.entries],
);
const [order, setOrder] = useState(authoritative);
const [adopted, setAdopted] = useState(authoritative);
if (adopted !== authoritative) {
setAdopted(authoritative);
setOrder(authoritative);
}
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const byId = useMemo(
() => new Map(playlist.entries.map((entry) => [entry.id, entry])),
[playlist.entries],
);
const entries = order.map((id) => byId.get(id)).filter(Boolean);
if (playlist.entries.length === 0) {
return (
<div className="empty-state" data-testid="empty-playlist">
<h2>This playlist has no slides yet</h2>
<p>Drop images anywhere on this page, or use the actions below.</p>
<div className="empty-actions">
<button type="button" className="button primary" onClick={onUploadRequest}>
Upload Slides
</button>
<button type="button" className="button" onClick={onLibraryRequest}>
Add from Library
</button>
</div>
</div>
);
}
async function handleDragEnd(event) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = order.indexOf(active.id);
const to = order.indexOf(over.id);
if (from < 0 || to < 0) return;
const next = arrayMove(order, from, to);
const previous = order;
setOrder(next);
try {
await onReorder(next);
} catch {
// The authoritative order wins; the grid snaps back without losing work.
setOrder(previous);
}
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={order} strategy={rectSortingStrategy}>
{/* Position in the grid is the sequence; no numbers are stamped on tiles. */}
<ul className="tile-grid" data-testid="tile-grid">
{entries.map((entry) => (
<Tile
key={entry.id}
entry={entry}
onToggle={onToggleEntry}
busy={busyEntryIds.has(entry.id)}
/>
))}
</ul>
</SortableContext>
</DndContext>
);
}
+64
View File
@@ -0,0 +1,64 @@
import ScenePreview from "../display/ScenePreview.jsx";
const TRANSPORT_LABELS = {
playing: "Rotating",
paused: "Paused",
static: "Static",
idle: "No playable slides",
};
function relativeTime(iso) {
if (!iso) return "never";
const seconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours} h ago`;
return `${Math.round(hours / 24)} d ago`;
}
export default function StatusPanel({ snapshot, displays }) {
// v2 has a single global output; if stray records exist from testing, prefer
// any that is online rather than showing an arbitrary one.
const display = displays.find((item) => item.status === "online") || displays[0] || null;
const online = display?.status === "online";
const transport = snapshot?.mode === "off" ? "Output Off" : TRANSPORT_LABELS[snapshot?.transportState] || "—";
const detail = display
? [
`Last seen ${relativeTime(display.lastSeenAt)}`,
display.cachedAssetCount !== null && display.cachedAssetCount !== undefined
? `${display.cachedAssetCount} slides cached`
: null,
display.mostRecentError
? `Error: ${display.mostRecentError.kind || display.mostRecentError}`
: null,
]
.filter(Boolean)
.join(" · ")
: "No display has connected yet";
return (
<section className="status-panel" aria-label="Output status">
<div className="preview-frame">
<ScenePreview snapshot={snapshot} />
</div>
<div className="status-rows">
<div className="status-row" data-testid="display-status" data-online={online ? "true" : "false"}>
<span className="status-dot" data-online={online ? "true" : "false"} aria-hidden="true" />
<span className="status-label">{online ? "Display Online" : "Display Offline"}</span>
<span className="status-detail" title={detail}>
{detail}
</span>
</div>
<div className="status-row" data-testid="playback-status">
<span className="status-label">{transport}</span>
<span className="status-detail">
{snapshot?.activePlaylist?.name ? `Playing ${snapshot.activePlaylist.name}` : "No active playlist"}
</span>
</div>
</div>
</section>
);
}
+16
View File
@@ -0,0 +1,16 @@
// Application-level messaging. The dashboard never uses alert/confirm/prompt.
export default function Toasts({ toasts, onDismiss }) {
if (toasts.length === 0) return null;
return (
<div className="toasts" role="status" aria-live="polite" data-testid="toasts">
{toasts.map((toast) => (
<div key={toast.id} className="toast" data-tone={toast.tone} data-testid="toast">
<span>{toast.message}</span>
<button type="button" onClick={() => onDismiss(toast.id)} aria-label="Dismiss message">
×
</button>
</div>
))}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
// Per-file upload state. The dashboard stays usable while these run, and the
// list distinguishes every state the operator needs to act on.
const STATE_LABELS = {
queued: "Queued",
uploading: "Uploading",
processing: "Processing",
complete: "Added",
duplicate: "Already in library",
already_in_playlist: "Already in this playlist",
failed: "Failed",
};
export default function UploadQueue({ items, onDismiss }) {
if (items.length === 0) return null;
return (
<section className="upload-queue" data-testid="upload-queue" aria-live="polite">
<header>
<h2>Uploads</h2>
<button type="button" className="button subtle" onClick={onDismiss}>
Clear finished
</button>
</header>
<ul>
{items.map((item) => (
<li key={item.id} data-testid="upload-item" data-state={item.state}>
<span className="upload-name">{item.name}</span>
<span className="upload-state" data-state={item.state}>
{STATE_LABELS[item.state] || item.state}
</span>
{item.state === "uploading" ? (
<span className="upload-bar" aria-hidden="true">
<span style={{ width: `${Math.round(item.progress * 100)}%` }} />
</span>
) : null}
{item.message ? <span className="upload-message">{item.message}</span> : null}
</li>
))}
</ul>
</section>
);
}
+89
View File
@@ -0,0 +1,89 @@
// A small explicit API layer. Components call these helpers instead of
// scattering fetch calls, but this is deliberately not a generated SDK or a
// generic data-repository framework.
async function request(path, { method = "GET", body, signal } = {}) {
const response = await fetch(path, {
method,
signal,
...(body === undefined
? {}
: { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
});
const text = await response.text();
const parsed = text ? JSON.parse(text) : null;
if (!response.ok) {
const error = new Error(parsed?.error?.message || `Request failed (${response.status})`);
error.code = parsed?.error?.code || "REQUEST_FAILED";
error.status = response.status;
error.details = parsed?.error?.details;
throw error;
}
return parsed;
}
export const api = {
listPlaylists: () => request("/api/playlists").then((body) => body.playlists),
getPlaylist: (playlistId) => request(`/api/playlists/${playlistId}`),
createPlaylist: (name) => request("/api/playlists", { method: "POST", body: { name } }),
activatePlaylist: (playlistId) =>
request(`/api/playlists/${playlistId}/activate`, { method: "POST" }),
addEntry: (playlistId, assetId) =>
request(`/api/playlists/${playlistId}/entries`, { method: "POST", body: { assetId } }),
updateEntry: (playlistId, entryId, patch) =>
request(`/api/playlists/${playlistId}/entries/${entryId}`, { method: "PATCH", body: patch }),
reorderEntries: (playlistId, entryIds) =>
request(`/api/playlists/${playlistId}/entries/order`, {
method: "PUT",
body: { entryIds },
}),
listAssets: () => request("/api/assets").then((body) => body.assets),
listDisplays: () => request("/api/displays").then((body) => body.displays),
getPlayback: () => request("/api/playback"),
};
// Uploads use the existing Phase 2 multipart pipeline. The browser never talks
// to object storage directly, and progress needs XHR rather than fetch.
export function uploadAsset(file, { onProgress } = {}) {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append("files", file, file.name);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/assets");
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress?.(event.loaded / event.total);
});
xhr.addEventListener("load", () => {
let parsed;
try {
parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null;
} catch {
parsed = null;
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(parsed);
return;
}
const error = new Error(parsed?.error?.message || `Upload failed (${xhr.status})`);
error.code = parsed?.error?.code || "UPLOAD_FAILED";
error.status = xhr.status;
reject(error);
});
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
xhr.send(form);
});
}
export const ACCEPTED_UPLOAD_TYPES = ["image/png", "image/jpeg", "image/webp"];
export function isAcceptedFile(file) {
if (ACCEPTED_UPLOAD_TYPES.includes(file.type)) return true;
// Some browsers report an empty type; fall back to the extension.
return /\.(png|jpe?g|webp)$/i.test(file.name || "");
}
// Stored display names keep their original case; presentation uppercases them
// with CSS. File extensions are never part of the visible slide name.
export function slideLabel(asset) {
const name = asset?.displayName || asset?.originalFilename || "Untitled";
return name.replace(/\.(png|jpe?g|webp|gif|bmp|tiff?)$/i, "");
}
+441
View File
@@ -0,0 +1,441 @@
/* Desktop management surface. Slide imagery dominates; chrome stays quiet.
None of this is applied to /display, which keeps its own scoped styling. */
.dashboard {
--surface: #0f1419;
--panel: #161d25;
--line: #232d38;
--text: #eef2f6;
--muted: #93a1b0;
--accent: #3b82f6;
--ok: #22c55e;
--warn: #f59e0b;
--bad: #ef4444;
min-height: 100vh;
background: var(--surface);
color: var(--text);
font-family: "Roboto", system-ui, -apple-system, "Segoe UI", sans-serif;
display: flex;
flex-direction: column;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.dashboard-header {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.9rem 1.5rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.header-identity {
display: flex;
align-items: center;
gap: 1rem;
}
.product {
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
font-size: 0.8rem;
}
.playlist-choice {
display: flex;
align-items: center;
gap: 0.5rem;
}
.playlist-choice select {
background: var(--surface);
color: var(--text);
border: 1px solid var(--line);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font: inherit;
min-width: 14rem;
}
.header-activation {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.badge {
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.08em;
padding: 0.3rem 0.6rem;
border-radius: 999px;
border: 1px solid currentColor;
}
.badge.active { color: var(--ok); }
.badge.pending { color: var(--warn); }
.badge.inactive { color: var(--muted); }
.button {
font: inherit;
border-radius: 0.5rem;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
padding: 0.5rem 0.9rem;
cursor: pointer;
}
.button:hover { border-color: var(--muted); }
.button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.button[disabled] { opacity: 0.5; cursor: default; }
.button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.button.subtle { background: transparent; color: var(--muted); }
.dashboard-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 22rem;
gap: 1.5rem;
padding: 1.5rem;
flex: 1;
align-items: start;
}
.playlist-heading h1 {
margin: 0;
font-size: 1.35rem;
font-weight: 500;
}
.playlist-heading .muted,
.playlist-heading .warning {
margin: 0.25rem 0 1rem;
font-size: 0.85rem;
}
.muted { color: var(--muted); }
.warning { color: var(--warn); }
/* Position in the grid is the sequence; tiles carry no ordinal numbers. */
.tile-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 1.1rem;
}
.tile {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tile[data-dragging="true"] {
z-index: 5;
filter: drop-shadow(0 1rem 1.5rem rgba(0, 0, 0, 0.55));
}
.tile-surface {
position: relative;
padding: 0;
border: none;
background: #000;
border-radius: 0.7rem;
overflow: hidden;
aspect-ratio: 16 / 9;
cursor: pointer;
display: block;
width: 100%;
}
.tile-surface:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.tile-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tile[data-enabled="false"] .tile-image { opacity: 0.4; filter: grayscale(0.7); }
.tile-image-missing { background: #22303c; }
/* The ring reads clearly without covering the artwork. */
.tile-ring {
position: absolute;
inset: 0;
border-radius: 0.7rem;
border: 3px solid transparent;
pointer-events: none;
}
.tile-ring[data-ring="playable"] { border-color: var(--ok); }
.tile-ring[data-ring="disabled"] { border-color: #6b7785; }
.tile-ring[data-ring="scheduled"] { border-color: var(--warn); }
.tile-ring[data-ring="error"] { border-color: var(--bad); }
/* Status is always available as text, never colour alone. */
.tile-status {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0.35rem 0.5rem;
font-size: 0.72rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
color: #fff;
text-align: left;
}
.tile-drag {
position: absolute;
top: 0.4rem;
right: 0.4rem;
padding: 0.15rem 0.4rem;
border-radius: 0.4rem;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: grab;
font-size: 0.9rem;
line-height: 1.2;
}
.tile-drag:focus-visible { outline: 2px solid var(--accent); }
.tile-name {
font-size: 0.82rem;
font-weight: 500;
letter-spacing: 0.03em;
/* Presentation only: the stored display name keeps its original case. */
text-transform: uppercase;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-state {
border: 2px dashed var(--line);
border-radius: 1rem;
padding: 4rem 2rem;
text-align: center;
}
.empty-state h2 { margin: 0 0 0.5rem; font-weight: 500; }
.empty-state p { color: var(--muted); margin: 0 0 1.5rem; }
.empty-actions { display: flex; gap: 0.75rem; justify-content: center; }
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 1.1rem;
}
.skeleton-tile {
aspect-ratio: 16 / 9;
border-radius: 0.7rem;
background: var(--panel);
}
.status-panel {
position: sticky;
top: 1.5rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 0.9rem;
overflow: hidden;
}
.preview-frame { background: #000; }
.status-rows { padding: 0.9rem 1rem; display: grid; gap: 0.75rem; }
.status-row {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.5rem 0.6rem;
}
.status-dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--bad);
}
.status-dot[data-online="true"] { background: var(--ok); }
.status-label { font-weight: 500; font-size: 0.9rem; }
.status-detail {
grid-column: 1 / -1;
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-queue {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
width: 22rem;
max-height: 40vh;
overflow: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 0.8rem;
padding: 0.75rem 0.9rem;
}
.upload-queue header { display: flex; justify-content: space-between; align-items: center; }
.upload-queue h2 { margin: 0; font-size: 0.85rem; font-weight: 500; }
.upload-queue ul { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.5rem; }
.upload-queue li { display: grid; grid-template-columns: 1fr auto; gap: 0.25rem 0.5rem; font-size: 0.78rem; }
.upload-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload-state { color: var(--muted); }
.upload-state[data-state="complete"] { color: var(--ok); }
.upload-state[data-state="failed"] { color: var(--bad); }
.upload-state[data-state="duplicate"],
.upload-state[data-state="already_in_playlist"] { color: var(--warn); }
.upload-message { grid-column: 1 / -1; color: var(--muted); }
.upload-bar { grid-column: 1 / -1; height: 3px; background: var(--line); border-radius: 2px; }
.upload-bar span { display: block; height: 100%; background: var(--accent); border-radius: 2px; }
.library-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 30rem;
background: var(--panel);
border-left: 1px solid var(--line);
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 20;
}
.library-panel header { display: flex; justify-content: space-between; align-items: center; }
.library-panel h2 { margin: 0; font-size: 1rem; font-weight: 500; }
.library-search {
font: inherit;
padding: 0.5rem 0.7rem;
border-radius: 0.5rem;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
}
.library-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 0.75rem;
overflow: auto;
flex: 1;
}
.library-grid button {
width: 100%;
padding: 0;
border: 2px solid transparent;
border-radius: 0.6rem;
background: var(--surface);
color: var(--text);
cursor: pointer;
overflow: hidden;
display: grid;
gap: 0.25rem;
}
.library-grid li[data-selected="true"] button { border-color: var(--accent); }
.library-grid button[disabled] { opacity: 0.55; cursor: default; }
.library-grid img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block; }
.library-image-missing { width: 100%; aspect-ratio: 16 / 9; background: #22303c; display: block; }
.library-name {
font-size: 0.72rem;
padding: 0 0.4rem;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-tag { font-size: 0.65rem; color: var(--muted); padding: 0 0.4rem 0.4rem; }
.library-tag.warn { color: var(--bad); }
.library-tag[data-expired="true"] { color: var(--bad); }
.toasts {
position: fixed;
left: 50%;
bottom: 1.5rem;
transform: translateX(-50%);
display: grid;
gap: 0.5rem;
z-index: 30;
}
.toast {
display: flex;
gap: 1rem;
align-items: center;
background: var(--panel);
border: 1px solid var(--line);
border-left: 3px solid var(--muted);
border-radius: 0.5rem;
padding: 0.6rem 0.9rem;
font-size: 0.85rem;
}
.toast[data-tone="error"] { border-left-color: var(--bad); }
.toast[data-tone="success"] { border-left-color: var(--ok); }
.toast button { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; }
.drop-overlay {
position: fixed;
inset: 0;
background: rgba(15, 20, 25, 0.75);
border: 3px dashed var(--accent);
display: grid;
place-items: center;
font-size: 1.1rem;
z-index: 40;
pointer-events: none;
}
.mobile-splash {
min-height: 100vh;
display: grid;
place-content: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
background: #0f1419;
color: #eef2f6;
font-family: "Roboto", system-ui, sans-serif;
}
.mobile-splash h1 { margin: 0; font-weight: 500; }
.mobile-splash .muted { color: #93a1b0; font-size: 0.9rem; max-width: 28rem; }
+38
View File
@@ -0,0 +1,38 @@
// Ring semantics for a playlist entry. Status is never conveyed by colour
// alone: every tile also carries this text for hover, focus, and assistive
// technology.
function formatInstant(value) {
try {
return new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return "a scheduled time";
}
}
export function entryStatus(entry) {
if (!entry.enabled) return { ring: "disabled", label: "Disabled" };
const reason = entry.notPlayableReason;
if (reason === "asset_not_ready" || reason === "asset_expired") {
return {
ring: "error",
label: reason === "asset_expired" ? "Asset expired" : "Asset unavailable",
};
}
if (reason === "scheduled_future") {
return {
ring: "scheduled",
label: entry.scheduleStartAt
? `Scheduled — available ${formatInstant(entry.scheduleStartAt)}`
: "Scheduled — not yet available",
};
}
if (reason === "scheduled_ended") {
return { ring: "scheduled", label: "Scheduled period ended" };
}
return { ring: "playable", label: "Enabled" };
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useRef } from "react";
import { io } from "socket.io-client";
import { createAssetManager } from "./assetCache.js";
import { createDisplayController } from "./displayController.js";
import { DISPLAY_ERROR_KINDS, createDisplayError } from "./errors.js";
import {
createDisplayPersistence,
createDisplayStore,
openDisplayDatabase,
} from "./displayDb.js";
import { createSceneLayer } from "./sceneLayer.js";
import "./display.css";
function createClientId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
return `display-${Math.random().toString(16).slice(2)}-${Date.now()}`;
}
// React renders this skeleton exactly once. The controller owns the pixels, so
// no scene element is ever recreated by a re-render.
function SceneMarkup({ layerRef, index }) {
return (
<div className="scene" data-layer={index} ref={layerRef}>
<div className="scene-background">
<img className="scene-background-image" alt="" aria-hidden="true" />
</div>
<div className="scene-darken" />
<img className="scene-foreground" alt="" />
</div>
);
}
export default function DisplayApp() {
const layerZeroRef = useRef(null);
const layerOneRef = useRef(null);
const gateRef = useRef(null);
const controllerRef = useRef(null);
useEffect(() => {
let cancelled = false;
let controller = null;
let database = null;
async function boot() {
document.body.dataset.displayRoute = "true";
let persistence = null;
let clientId;
try {
database = await openDisplayDatabase();
const store = createDisplayStore({ database });
persistence = createDisplayPersistence({ store, createId: createClientId });
clientId = await persistence.getOrCreateClientId();
} catch {
// A display with no usable IndexedDB still shows slides; it simply
// cannot persist its identity between restarts.
clientId = createClientId();
}
if (cancelled) return;
// Handlers are registered before the socket connects so the first
// authoritative snapshot can never be missed.
const socket = io({ autoConnect: false });
controller = createDisplayController({
assets: createAssetManager(),
clientId,
gate: gateRef.current,
layers: [
createSceneLayer(layerZeroRef.current),
createSceneLayer(layerOneRef.current),
],
persistence,
socket,
});
controllerRef.current = controller;
if (typeof window !== "undefined") {
// Test and support hook only; it exposes no private storage details.
window.__display = {
clientId,
diagnostics: () => controller.diagnostics(),
heartbeat: () => controller.sendHeartbeat(),
};
}
// Offline capability needs a secure context. Rather than silently
// degrading, the display reports it so it is diagnosable from
// /api/displays and Home Assistant instead of on screen.
if (!window.isSecureContext || !("serviceWorker" in navigator)) {
controller.reportCapabilityLimit(
createDisplayError(DISPLAY_ERROR_KINDS.OFFLINE_CAPABILITY_UNAVAILABLE, {
message: window.isSecureContext
? "This browser provides no Service Worker; offline startup is unavailable"
: "This origin is not a secure context; offline caching is unavailable",
}),
);
}
// Restore signage from the last known good state before any server
// contact, so a cold start with the infrastructure down still shows
// announcements.
await controller.bootstrapFromPersistence();
if (cancelled) return;
controller.start();
}
void boot();
return () => {
cancelled = true;
controller?.destroy();
controllerRef.current = null;
if (typeof window !== "undefined") delete window.__display;
try {
database?.close();
} catch {
// Closing a database that never opened is not an error worth raising.
}
delete document.body.dataset.displayRoute;
};
}, []);
return (
<div className="display-root" data-testid="display-root">
<SceneMarkup index={0} layerRef={layerZeroRef} />
<SceneMarkup index={1} layerRef={layerOneRef} />
<div
className="output-gate"
data-testid="output-gate"
data-active="false"
ref={gateRef}
/>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { resolveScenePresentation } from "./presentation.js";
import "./scene.css";
// The dashboard's live preview and the kiosk renderer must never drift apart.
// This component reuses the same presentation resolver and the same scene CSS
// the signage output uses; only the container is different.
export default function ScenePreview({ snapshot, className = "" }) {
const target = snapshot?.staticTarget || snapshot?.normalTarget || null;
const mediaUrl = target?.asset?.media?.url ?? null;
const presentation = target ? resolveScenePresentation(target) : null;
const modeOff = snapshot?.mode === "off";
return (
<div
className={`scene-preview ${className}`.trim()}
data-testid="scene-preview"
data-mode={snapshot?.mode ?? "unknown"}
data-target={target ? (snapshot?.staticTarget ? "static" : "normal") : "black"}
>
{presentation && mediaUrl ? (
<div
className="scene"
data-visible="true"
data-preview-layer="true"
data-fit={presentation.fit}
data-background-mode={presentation.backgroundMode}
data-darkened={presentation.darkened ? "true" : "false"}
data-shadow={presentation.shadow ? "true" : "false"}
style={{ opacity: 1 }}
>
<div
className="scene-background"
style={{
backgroundColor:
presentation.backgroundMode === "solid"
? presentation.backgroundColor
: "#000000",
}}
>
<img
className="scene-background-image"
alt=""
aria-hidden="true"
src={presentation.backgroundImage ? mediaUrl : undefined}
style={{
display: presentation.backgroundImage ? "block" : "none",
filter: presentation.backgroundImage
? `blur(${presentation.blurPx}px)`
: "none",
}}
/>
</div>
<div className="scene-darken" style={{ opacity: presentation.darkenOpacity }} />
<img
className="scene-foreground"
alt=""
src={mediaUrl}
style={{
objectFit: presentation.fit === "fill" ? "cover" : "contain",
filter: presentation.shadow
? "drop-shadow(0 0.6vh 1.6vh rgba(0, 0, 0, 0.45))"
: "none",
}}
/>
</div>
) : null}
{/* Mode off is the same topmost black gate the kiosk applies. */}
<div
className="output-gate"
data-testid="preview-gate"
data-active={modeOff ? "true" : "false"}
style={{ opacity: modeOff ? 1 : 0 }}
/>
</div>
);
}
@@ -0,0 +1,223 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createDisplayError,
serializeDisplayError,
shouldClearError,
MAX_SERIALIZED_LENGTH,
} from "../errors.js";
import { isSixteenByNine, resolveScenePresentation } from "../presentation.js";
import {
canPredictLocally,
classifySnapshot,
entryPlayableAt,
predictNextEntry,
targetKey,
} from "../snapshot.js";
function entry(overrides = {}) {
return {
id: "entry-a",
position: 0,
assetId: "asset-a",
effectiveDurationMs: 5_000,
scheduleStartAt: null,
scheduleEndAt: null,
expirationAt: null,
playable: true,
effectivePresentation: {
backgroundMode: "blurred",
backgroundColor: "#000000",
backgroundDarkened: false,
blurPx: 20,
backgroundDarkeningOpacity: 0,
},
asset: { id: "asset-a", media: { url: "/media/a.webp", sha256: "a", width: 1920, height: 1080 } },
...overrides,
};
}
function snapshot(overrides = {}) {
return {
playbackRevision: 5,
settingsRevision: 2,
serverTime: 1_000,
mode: "on",
transportState: "playing",
activePlaylistId: "playlist-a",
pendingPlaylistId: null,
currentEntryId: "entry-a",
crossfadeDurationMs: 500,
timeline: { slideStartedAt: 0, nextSlideAt: 5_000 },
normalTarget: { type: "normal", ...entry() },
staticTarget: null,
manifest: { playlistId: "playlist-a", playlistRevision: 3, entries: [entry()] },
...overrides,
};
}
test("16:9 detection uses real pixel dimensions with a small tolerance", () => {
assert.equal(isSixteenByNine(1920, 1080), true);
assert.equal(isSixteenByNine(1918, 1080), true, "near-16:9 encodes still fill");
assert.equal(isSixteenByNine(1280, 720), true);
assert.equal(isSixteenByNine(1600, 1000), false, "16:10 is not 16:9");
assert.equal(isSixteenByNine(1080, 1920), false, "portrait is not 16:9");
assert.equal(isSixteenByNine(1024, 768), false, "4:3 is not 16:9");
assert.equal(isSixteenByNine(0, 0), false);
});
test("a true 16:9 slide fills the frame with no backdrop or shadow", () => {
const presentation = resolveScenePresentation(entry());
assert.equal(presentation.fit, "fill");
assert.equal(presentation.backgroundImage, false);
assert.equal(presentation.backgroundMode, "none");
assert.equal(presentation.shadow, false);
assert.equal(presentation.darkenOpacity, 0);
});
test("a non-16:9 slide is contained over its configured background", () => {
const portrait = entry({
asset: { id: "p", media: { url: "/media/p.webp", sha256: "p", width: 1080, height: 1920 } },
});
const blurred = resolveScenePresentation(portrait);
assert.equal(blurred.fit, "contain");
assert.equal(blurred.backgroundMode, "blurred");
assert.equal(blurred.backgroundImage, true);
assert.equal(blurred.blurPx, 20, "the global default blur is used");
assert.equal(blurred.shadow, true);
const solid = resolveScenePresentation({
...portrait,
effectivePresentation: {
backgroundMode: "solid",
backgroundColor: "#123456",
backgroundDarkened: true,
blurPx: 20,
backgroundDarkeningOpacity: 0.25,
},
});
assert.equal(solid.backgroundMode, "solid");
assert.equal(solid.backgroundColor, "#123456");
assert.equal(solid.backgroundImage, false);
assert.equal(solid.darkened, true);
assert.equal(solid.darkenOpacity, 0.25);
const black = resolveScenePresentation({
...portrait,
effectivePresentation: { backgroundMode: "black", backgroundColor: "#000000", blurPx: 20 },
});
assert.equal(black.backgroundMode, "black");
assert.equal(black.backgroundImage, false);
assert.equal(black.darkenOpacity, 0);
});
test("a stale playback revision is rejected but an equal one is not", () => {
const accepted = snapshot();
assert.equal(classifySnapshot(null, accepted).accept, true);
assert.equal(
classifySnapshot(accepted, snapshot({ playbackRevision: 4 })).accept,
false,
"an older playback revision is stale",
);
assert.equal(classifySnapshot(accepted, snapshot({ playbackRevision: 6 })).accept, true);
});
test("manifest and settings changes are accepted at an unchanged playback revision", () => {
const accepted = snapshot();
const settings = classifySnapshot(accepted, snapshot({ settingsRevision: 3 }));
assert.equal(settings.accept, true);
assert.equal(settings.reason, "settings_revision");
const reordered = classifySnapshot(
accepted,
snapshot({
manifest: {
playlistId: "playlist-a",
playlistRevision: 4,
entries: [entry({ id: "entry-b", position: 1 }), entry()],
},
}),
);
assert.equal(reordered.accept, true);
assert.equal(reordered.reason, "manifest");
const identical = classifySnapshot(accepted, snapshot());
assert.equal(identical.accept, true);
assert.equal(identical.unchanged, true, "an identical snapshot implies no visual work");
});
test("schedules are half open and rotation skips locally unavailable entries", () => {
const scheduled = entry({ scheduleStartAt: 100, scheduleEndAt: 200 });
assert.equal(entryPlayableAt(scheduled, 99), false);
assert.equal(entryPlayableAt(scheduled, 100), true, "active exactly at start");
assert.equal(entryPlayableAt(scheduled, 199), true);
assert.equal(entryPlayableAt(scheduled, 200), false, "inactive exactly at end");
const manifest = {
entries: [
entry({ id: "a", position: 0 }),
entry({ id: "b", position: 1 }),
entry({ id: "c", position: 2 }),
],
};
assert.equal(predictNextEntry(manifest, "a", 0).id, "b");
assert.equal(predictNextEntry(manifest, "c", 0).id, "a", "rotation wraps");
assert.equal(predictNextEntry(manifest, "a", 0, ["b"]).id, "c", "a bad asset is skipped");
assert.equal(predictNextEntry(manifest, "a", 0, ["b", "c"]).id, "a");
assert.equal(predictNextEntry({ entries: [] }, "a", 0), null);
});
test("local prediction is withheld when a pending activation owns the next boundary", () => {
assert.equal(canPredictLocally(snapshot()), true);
assert.equal(
canPredictLocally(snapshot({ pendingPlaylistId: "playlist-b" })),
false,
"the next boundary belongs to a playlist the display may not hold yet",
);
assert.equal(canPredictLocally(snapshot({ transportState: "paused" })), false);
assert.equal(
canPredictLocally(snapshot({ staticTarget: { assetId: "s" }, transportState: "static" })),
false,
);
assert.equal(canPredictLocally(snapshot({ mode: "off" })), true, "mode off still advances");
});
test("target keys distinguish mode, static, normal, and empty output", () => {
assert.equal(targetKey(snapshot({ mode: "off" })), "black:mode_off");
assert.match(targetKey(snapshot()), /^normal:entry-a:a$/);
assert.match(
targetKey(snapshot({ staticTarget: { assetId: "s", asset: { media: { sha256: "z" } } } })),
/^static:s:z$/,
);
assert.equal(targetKey(snapshot({ normalTarget: null })), "black:no_playable_content");
});
test("display errors are structured, bounded, and cleared only when resolved", () => {
const error = createDisplayError("slide_decode_failed", {
assetId: "asset-a",
entryId: "entry-a",
message: "x".repeat(5_000),
});
assert.equal(error.kind, "slide_decode_failed");
assert.ok(error.message.length <= 240);
const serialized = serializeDisplayError(error);
assert.ok(serialized.length <= MAX_SERIALIZED_LENGTH);
assert.equal(JSON.parse(serialized).assetId, "asset-a");
assert.throws(() => createDisplayError("nonsense"), TypeError);
assert.equal(
shouldClearError(error, { renderedAssetId: "asset-b", manifestAssetIds: ["asset-a", "asset-b"] }),
false,
"a still-present problem is never cleared by time alone",
);
assert.equal(
shouldClearError(error, { renderedAssetId: "asset-a", manifestAssetIds: ["asset-a"] }),
true,
"the failing asset rendering resolves the condition",
);
assert.equal(
shouldClearError(error, { renderedAssetId: "asset-b", manifestAssetIds: ["asset-b"] }),
true,
"the failing entry leaving the manifest resolves the condition",
);
});
@@ -0,0 +1,263 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
nextOfflineBoundary,
resolveOfflineState,
} from "../offlineTimeline.js";
const T0 = 2_000_000_000_000;
function entry(id, position, overrides = {}) {
return {
id,
position,
assetId: `asset-${id}`,
effectiveDurationMs: 5_000,
scheduleStartAt: null,
scheduleEndAt: null,
expirationAt: null,
playable: true,
asset: { id: `asset-${id}`, media: { url: `/media/${id}.webp`, width: 1920, height: 1080 } },
...overrides,
};
}
const MANIFEST = {
entries: [
entry("a", 0, { effectiveDurationMs: 3_000 }),
entry("b", 1, { effectiveDurationMs: 9_000 }),
entry("c", 2, { effectiveDurationMs: 4_000 }),
],
};
function anchor(overrides = {}) {
return {
entryId: "a",
startedAt: T0,
deadlineAt: T0 + 3_000,
transportState: "playing",
mode: "on",
pausedRemainingMs: null,
staticAssetId: null,
staticTarget: null,
pendingPlaylistId: null,
...overrides,
};
}
test("mixed durations advance on absolute deadlines without drift", () => {
const cycle = 3_000 + 9_000 + 4_000;
const cases = [
[0, "a", 0],
[2_999, "a", 0],
[3_000, "b", 3_000],
[11_999, "b", 3_000],
[12_000, "c", 12_000],
[15_999, "c", 12_000],
[cycle, "a", cycle],
];
for (const [offset, expectedId, expectedStart] of cases) {
const resolved = resolveOfflineState({ manifest: MANIFEST, anchor: anchor(), at: T0 + offset });
assert.equal(resolved.kind, "normal", `+${offset}`);
assert.equal(resolved.entryId, expectedId, `+${offset} entry`);
assert.equal(resolved.startedAt, T0 + expectedStart, `+${offset} start`);
}
});
test("many cycles offline stay exactly on the original phase", () => {
const cycle = 16_000;
for (const cycles of [10, 1_000, 100_000]) {
const resolved = resolveOfflineState({
manifest: MANIFEST,
anchor: anchor(),
at: T0 + cycles * cycle + 12_500,
});
assert.equal(resolved.entryId, "c");
assert.equal(
resolved.startedAt,
T0 + cycles * cycle + 12_000,
"no accumulated drift after many cycles",
);
}
});
test("a schedule start offline brings an entry back into rotation", () => {
const manifest = {
entries: [
entry("always", 0, { effectiveDurationMs: 1_000 }),
entry("joins", 1, { effectiveDurationMs: 1_000, scheduleStartAt: T0 + 20_000 }),
],
};
const before = resolveOfflineState({
manifest,
anchor: anchor({ entryId: "always", deadlineAt: T0 + 1_000 }),
at: T0 + 19_500,
});
assert.equal(before.entryId, "always", "the future entry is not used early");
const after = resolveOfflineState({
manifest,
anchor: anchor({ entryId: "always", deadlineAt: T0 + 1_000 }),
at: T0 + 20_500,
});
assert.equal(after.entryId, "joins", "[start, end) is inclusive at start");
});
test("a schedule end and an expiration offline both retire an entry", () => {
const ending = {
entries: [
entry("ends", 0, { effectiveDurationMs: 5_000, scheduleEndAt: T0 + 12_500 }),
entry("stays", 1, { effectiveDurationMs: 5_000 }),
],
};
const afterEnd = resolveOfflineState({
manifest: ending,
anchor: anchor({ entryId: "ends", deadlineAt: T0 + 5_000 }),
at: T0 + 40_000,
});
assert.equal(afterEnd.entryId, "stays");
const expiring = {
entries: [
entry("expires", 0, { effectiveDurationMs: 6_000, expirationAt: T0 + 15_000 }),
entry("permanent", 1, { effectiveDurationMs: 4_000 }),
],
};
const afterExpiry = resolveOfflineState({
manifest: expiring,
anchor: anchor({ entryId: "expires", deadlineAt: T0 + 6_000 }),
at: T0 + 60_000,
});
assert.equal(afterExpiry.entryId, "permanent");
});
test("a cached playlist with nothing eligible is legitimately black", () => {
const manifest = { entries: [entry("only", 0, { scheduleEndAt: T0 + 8_000 })] };
const resolved = resolveOfflineState({
manifest,
anchor: anchor({ entryId: "only", deadlineAt: T0 + 5_000 }),
at: T0 + 30_000,
});
assert.equal(resolved.kind, "black");
assert.equal(resolved.reason, "no_playable_content");
});
test("mode off keeps advancing the hidden transport underneath", () => {
const resolved = resolveOfflineState({
manifest: MANIFEST,
anchor: anchor({ mode: "off" }),
at: T0 + 12_500,
});
// Mode is a visual gate applied by the renderer; the cursor still moves.
assert.equal(resolved.kind, "normal");
assert.equal(resolved.entryId, "c");
});
test("a paused display holds its slide and its remainder offline", () => {
const resolved = resolveOfflineState({
manifest: MANIFEST,
anchor: anchor({
transportState: "paused",
entryId: "b",
pausedRemainingMs: 4_000,
startedAt: null,
deadlineAt: null,
}),
at: T0 + 6 * 3_600_000,
});
assert.equal(resolved.kind, "paused");
assert.equal(resolved.entryId, "b", "pause never advances merely because time passed");
assert.equal(resolved.remainingMs, 4_000);
});
test("a paused display still reconciles a held slide that became ineligible", () => {
const manifest = {
entries: [
entry("held", 0, { expirationAt: T0 + 10_000 }),
entry("survivor", 1),
],
};
const resolved = resolveOfflineState({
manifest,
anchor: anchor({ transportState: "paused", entryId: "held", pausedRemainingMs: 2_000 }),
at: T0 + 20_000,
});
assert.equal(resolved.kind, "paused", "it stays paused");
assert.equal(resolved.entryId, "survivor", "but not on an expired slide");
});
test("static holds indefinitely offline and releases only on real expiration", () => {
const held = resolveOfflineState({
manifest: MANIFEST,
anchor: anchor({
transportState: "static",
staticAssetId: "static-1",
staticTarget: { assetId: "static-1", asset: { expirationAt: null } },
pausedRemainingMs: 2_000,
}),
at: T0 + 30 * 24 * 3_600_000,
});
assert.equal(held.kind, "static");
assert.equal(held.assetId, "static-1", "a permanent static asset holds forever");
const released = resolveOfflineState({
manifest: MANIFEST,
anchor: anchor({
entryId: "a",
transportState: "static",
staticAssetId: "static-1",
staticTarget: { assetId: "static-1", expirationAt: T0 + 10_000 },
pausedRemainingMs: 2_000,
}),
at: T0 + 11_000,
});
// Normal playback resumes from the expiration instant with its remainder.
assert.equal(released.kind, "normal");
assert.equal(released.entryId, "a");
assert.equal(released.deadlineAt, T0 + 12_000);
});
test("an unknown pending activation holds the last good frame at the boundary", () => {
const beforeBoundary = resolveOfflineState({
manifest: MANIFEST,
anchor: anchor({ pendingPlaylistId: "playlist-b" }),
at: T0 + 1_000,
});
assert.equal(beforeBoundary.kind, "normal", "the current slide still finishes");
const atBoundary = resolveOfflineState({
manifest: MANIFEST,
anchor: anchor({ pendingPlaylistId: "playlist-b" }),
at: T0 + 3_000,
});
assert.equal(atBoundary.kind, "hold");
assert.equal(
atBoundary.reason,
"pending_activation_unknown",
"never continue deeper into a playlist the server is replacing",
);
});
test("the next offline wake-up is the earliest real boundary", () => {
const resolved = resolveOfflineState({ manifest: MANIFEST, anchor: anchor(), at: T0 + 1_000 });
assert.equal(
nextOfflineBoundary({ manifest: MANIFEST, resolved, at: T0 + 1_000 }),
T0 + 3_000,
"the current slide deadline",
);
const scheduled = {
entries: [entry("future", 0, { scheduleStartAt: T0 + 50_000 })],
};
const black = resolveOfflineState({
manifest: scheduled,
anchor: anchor({ entryId: "future", startedAt: null, deadlineAt: null }),
at: T0,
});
assert.equal(black.kind, "black");
assert.equal(
nextOfflineBoundary({ manifest: scheduled, resolved: black, at: T0 }),
T0 + 50_000,
"a black display still wakes for a cached schedule start",
);
});
+183
View File
@@ -0,0 +1,183 @@
import { DISPLAY_ERROR_KINDS, createDisplayError } from "./errors.js";
// Signage reliability must not depend on ordinary HTTP caching. Media URLs are
// content-addressed and immutable, so bytes are stored explicitly here and a
// changed asset always arrives under a new URL.
export const ASSET_CACHE_NAME = "announcements-display-assets-v1";
export class DisplayAssetError extends Error {
constructor(displayError, cause) {
super(displayError.message);
this.name = "DisplayAssetError";
this.displayError = displayError;
this.cause = cause;
}
}
export function createAssetManager({
cacheName = ASSET_CACHE_NAME,
fetchFn = (...args) => fetch(...args),
caches: cacheStorage = typeof caches === "undefined" ? null : caches,
} = {}) {
// url -> { objectUrl, refCount }
const objectUrls = new Map();
let cachePromise = null;
function cache() {
if (!cacheStorage) {
return Promise.reject(new Error("Cache Storage is unavailable"));
}
cachePromise = cachePromise || cacheStorage.open(cacheName);
return cachePromise;
}
async function cachedUrls() {
try {
const store = await cache();
const keys = await store.keys();
return keys.map((request) => new URL(request.url).pathname);
} catch {
return [];
}
}
async function count() {
return (await cachedUrls()).length;
}
async function has(url) {
try {
const store = await cache();
return Boolean(await store.match(url));
} catch {
return false;
}
}
// Returns a blob for the URL, fetching and storing it only when the explicit
// cache does not already hold it.
async function loadBlob(url, { assetId = null, entryId = null } = {}) {
let store;
try {
store = await cache();
} catch (error) {
throw new DisplayAssetError(
createDisplayError(DISPLAY_ERROR_KINDS.CACHE_WRITE_FAILED, {
assetId,
entryId,
message: `Cache Storage unavailable: ${error?.message ?? "unknown"}`,
}),
error,
);
}
const cached = await store.match(url);
if (cached) return cached.blob();
let response;
try {
// The explicit cache is the source of truth, so the HTTP cache is bypassed.
response = await fetchFn(url, { cache: "no-store" });
} catch (error) {
throw new DisplayAssetError(
createDisplayError(DISPLAY_ERROR_KINDS.ASSET_FETCH_FAILED, {
assetId,
entryId,
message: `Fetch failed for ${url}: ${error?.message ?? "network error"}`,
}),
error,
);
}
if (!response.ok) {
throw new DisplayAssetError(
createDisplayError(DISPLAY_ERROR_KINDS.ASSET_FETCH_FAILED, {
assetId,
entryId,
message: `Fetch failed for ${url}: HTTP ${response.status}`,
}),
);
}
try {
await store.put(url, response.clone());
} catch {
// Quota pressure is recoverable: prune and try once more before giving up.
try {
await pruneTo(new Set(objectUrls.keys()));
await store.put(url, response.clone());
} catch (retryError) {
throw new DisplayAssetError(
createDisplayError(DISPLAY_ERROR_KINDS.CACHE_WRITE_FAILED, {
assetId,
entryId,
message: `Could not store ${url}: ${retryError?.message ?? "quota exceeded"}`,
}),
retryError,
);
}
}
return response.blob();
}
// Object URLs are reference counted so a layer that is still showing an image
// never has its bytes revoked underneath it.
async function acquire(url, context) {
const existing = objectUrls.get(url);
if (existing) {
existing.refCount += 1;
return existing.objectUrl;
}
const blob = await loadBlob(url, context);
const found = objectUrls.get(url);
if (found) {
found.refCount += 1;
return found.objectUrl;
}
const objectUrl = URL.createObjectURL(blob);
objectUrls.set(url, { objectUrl, refCount: 1 });
return objectUrl;
}
function release(url) {
const entry = objectUrls.get(url);
if (!entry) return;
entry.refCount -= 1;
if (entry.refCount > 0) return;
URL.revokeObjectURL(entry.objectUrl);
objectUrls.delete(url);
}
function activeObjectUrlCount() {
return objectUrls.size;
}
// Conservative pruning: anything still referenced by retained state or held
// by a live scene layer is kept.
async function pruneTo(retainedUrls) {
const retained = new Set(retainedUrls);
for (const url of objectUrls.keys()) retained.add(url);
let removed = 0;
try {
const store = await cache();
for (const request of await store.keys()) {
const path = new URL(request.url).pathname;
if (retained.has(path)) continue;
if (await store.delete(request)) removed += 1;
}
} catch {
// Pruning is opportunistic; a failure must never break playback.
}
return removed;
}
return {
acquire,
activeObjectUrlCount,
cachedUrls,
count,
has,
loadBlob,
pruneTo,
release,
};
}
+39
View File
@@ -0,0 +1,39 @@
@import "./scene.css";
/* Signage-only styling. These fullscreen constraints are scoped to the display
route so the future management dashboard is unaffected. */
body[data-display-route="true"] {
margin: 0;
padding: 0;
overflow: hidden;
background: #000;
cursor: none;
}
.display-root {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #000;
}
.display-root *:focus {
outline: none;
}
/* Topmost black gate for mode off. It covers the output instantly while the
renderer keeps tracking the authoritative timeline underneath. */
.output-gate {
position: absolute;
inset: 0;
background: #000;
opacity: 0;
pointer-events: none;
z-index: 10;
}
.output-gate[data-active="true"] {
opacity: 1;
}
+665
View File
@@ -0,0 +1,665 @@
import { createAssetManager, DisplayAssetError } from "./assetCache.js";
import { DISPLAY_ERROR_KINDS, createDisplayError, serializeDisplayError, shouldClearError } from "./errors.js";
import { nextOfflineBoundary, resolveOfflineState } from "./offlineTimeline.js";
import { resolveScenePresentation } from "./presentation.js";
import {
canPredictLocally,
classifySnapshot,
predictNextEntry,
targetKey,
} from "./snapshot.js";
export const HEARTBEAT_INTERVAL_MS = 12_000;
const HEARTBEAT_PROTOCOL_VERSION = 1;
function presentationSignature(presentation) {
return [
presentation.fit,
presentation.backgroundMode,
presentation.backgroundColor,
presentation.blurPx,
presentation.darkenOpacity,
presentation.shadow ? 1 : 0,
].join(":");
}
function delay(ms, setTimeoutFn) {
return new Promise((resolve) => setTimeoutFn(resolve, ms));
}
export function createDisplayController({
layers,
gate,
socket,
persistence,
clientId,
assets = createAssetManager(),
now = Date.now,
setTimeoutFn = (...args) => setTimeout(...args),
clearTimeoutFn = (...args) => clearTimeout(...args),
setIntervalFn = (...args) => setInterval(...args),
clearIntervalFn = (...args) => clearInterval(...args),
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS,
logger = { warn() {} },
onDiagnostics = () => {},
} = {}) {
let accepted = null;
let serverOffset = 0;
let generation = 0;
let currentIndex = 0;
let renderedSceneKey = null;
let renderedTarget = null;
let promotedManifest = { entries: [] };
let predictedEntryId = null;
let predictionTimer = null;
let heartbeatTimer = null;
let currentError = null;
let destroyed = false;
let stagingToken = 0;
let online = false;
let offlineTimer = null;
let offlineAnchor = null;
let offlineActive = false;
const layerUrls = [null, null];
const unavailableEntryIds = new Set();
const failedMediaUrls = new Set();
function diagnostics() {
const snapshot = {
acceptedRevision: accepted?.playbackRevision ?? null,
serverOffset,
generation,
currentIndex,
renderedSceneKey,
renderedAssetId: renderedTarget?.assetId ?? null,
renderedEntryId: renderedTarget?.entryId ?? null,
predictedEntryId,
online,
offlineActive,
offlineAnchor,
unavailableEntryIds: [...unavailableEntryIds],
mostRecentError: currentError,
activeObjectUrls: assets.activeObjectUrlCount(),
};
onDiagnostics(snapshot);
return snapshot;
}
function estimatedServerNow() {
return now() + serverOffset;
}
function recordError(displayError) {
currentError = displayError;
diagnostics();
}
// A capability limit is a standing condition, not a transient failure, so it
// is only recorded when nothing more specific is already being reported.
function reportCapabilityLimit(displayError) {
if (currentError) return;
recordError(displayError);
}
function clearErrorIfResolved() {
const manifestAssetIds = (promotedManifest.entries || []).map(
(entry) => entry.assetId,
);
if (
shouldClearError(currentError, {
renderedAssetId: renderedTarget?.assetId ?? null,
manifestAssetIds,
})
) {
currentError = null;
}
}
// ---------------------------------------------------------------- rendering
function buildScene(target, kind) {
const presentation = resolveScenePresentation(target);
const mediaUrl = target?.asset?.media?.url;
if (!mediaUrl) return null;
return {
assetId: target.assetId ?? target.asset?.id ?? null,
entryId: kind === "normal" ? target.id : null,
key: `${targetKey({
mode: "on",
staticTarget: kind === "static" ? target : null,
normalTarget: kind === "normal" ? target : null,
})}#${presentationSignature(presentation)}`,
kind,
mediaUrl,
presentation,
};
}
async function showScene(scene, token, crossfadeMs) {
if (destroyed || token !== generation) return false;
if (scene.key === renderedSceneKey) return true;
let objectUrl;
try {
objectUrl = await assets.acquire(scene.mediaUrl, {
assetId: scene.assetId,
entryId: scene.entryId,
});
} catch (error) {
if (error instanceof DisplayAssetError) {
failedMediaUrls.add(scene.mediaUrl);
if (scene.entryId) unavailableEntryIds.add(scene.entryId);
recordError(error.displayError);
return false;
}
throw error;
}
// Authoritative state may have moved on while bytes were loading.
if (destroyed || token !== generation) {
assets.release(scene.mediaUrl);
return false;
}
const incomingIndex = 1 - currentIndex;
const incoming = layers[incomingIndex];
const outgoing = layers[currentIndex];
const previousUrl = layerUrls[incomingIndex];
try {
await incoming.prepare({ ...scene, objectUrl });
} catch (error) {
assets.release(scene.mediaUrl);
failedMediaUrls.add(scene.mediaUrl);
if (scene.entryId) unavailableEntryIds.add(scene.entryId);
// A decode failure must never remove the last good frame.
recordError(
createDisplayError(DISPLAY_ERROR_KINDS.SLIDE_DECODE_FAILED, {
assetId: scene.assetId,
entryId: scene.entryId,
message: `Could not decode ${scene.mediaUrl}: ${error?.message ?? "decode failed"}`,
}),
);
return false;
}
if (destroyed || token !== generation) {
assets.release(scene.mediaUrl);
return false;
}
// The incoming layer rises above the outgoing one at zero opacity and fades
// in. The outgoing layer is never faded out, so the black root can never
// contribute to the composite mid-transition.
incoming.setOpacity(0, 0);
incoming.setStacking(2);
outgoing.setStacking(1);
incoming.forceReflow();
incoming.setOpacity(1, crossfadeMs);
if (crossfadeMs > 0) await delay(crossfadeMs, setTimeoutFn);
if (destroyed || token !== generation) {
// A newer transition won while this one was fading; it owns the layers.
return false;
}
currentIndex = incomingIndex;
layerUrls[incomingIndex] = scene.mediaUrl;
if (previousUrl && previousUrl !== scene.mediaUrl) assets.release(previousUrl);
renderedSceneKey = scene.key;
renderedTarget = {
assetId: scene.assetId,
entryId: scene.entryId,
kind: scene.kind,
mediaUrl: scene.mediaUrl,
};
clearErrorIfResolved();
diagnostics();
return true;
}
function setGate(on) {
gate.dataset.active = on ? "true" : "false";
gate.style.opacity = on ? "1" : "0";
}
function hideContent() {
for (const layer of layers) layer.setOpacity(0, 0);
renderedSceneKey = null;
}
// ------------------------------------------------------------------ staging
// A new manifest never destroys the working one. Assets are fetched and
// validated first; the manifest is promoted only once it is usable.
async function stageManifest(snapshot, token) {
const entries = snapshot.manifest?.entries || [];
let usable = 0;
for (const entry of entries) {
if (destroyed || token !== stagingToken) return;
const url = entry.asset?.media?.url;
if (!url) {
unavailableEntryIds.add(entry.id);
continue;
}
if (failedMediaUrls.has(url)) {
unavailableEntryIds.add(entry.id);
continue;
}
try {
await assets.loadBlob(url, { assetId: entry.assetId, entryId: entry.id });
unavailableEntryIds.delete(entry.id);
usable += 1;
} catch (error) {
// One bad asset marks only itself unavailable; the playlist survives.
unavailableEntryIds.add(entry.id);
if (error instanceof DisplayAssetError) recordError(error.displayError);
}
}
if (destroyed || token !== stagingToken) return;
const expectsPlayable = entries.some((entry) => entry.playable);
if (expectsPlayable && usable === 0) {
// The server believes this playlist is playable but nothing renders here.
// The last good frame stays up and the condition is reported.
recordError(
createDisplayError(DISPLAY_ERROR_KINDS.MANIFEST_INVALID, {
message: `No playable slide of playlist ${
snapshot.activePlaylistId ?? "unknown"
} could be loaded locally`,
}),
);
}
promotedManifest = snapshot.manifest || { entries: [] };
clearErrorIfResolved();
const retained = new Set();
for (const entry of promotedManifest.entries || []) {
if (entry.asset?.media?.url) retained.add(entry.asset.media.url);
}
if (snapshot.staticTarget?.asset?.media?.url) {
retained.add(snapshot.staticTarget.asset.media.url);
}
for (const url of layerUrls) if (url) retained.add(url);
await assets.pruneTo(retained);
try {
await persistence?.writeLastKnownGood(snapshot, {
mostRecentError: currentError,
});
} catch {
// Persistence is best effort and never blocks rendering.
}
diagnostics();
}
// --------------------------------------------------------------- prediction
function clearPrediction() {
if (predictionTimer !== null) {
clearTimeoutFn(predictionTimer);
predictionTimer = null;
}
}
function armPrediction(serverDeadline) {
clearPrediction();
if (destroyed || !accepted) return;
if (!canPredictLocally(accepted)) return;
const localDelay = Math.max(0, serverDeadline - serverOffset - now());
predictionTimer = setTimeoutFn(() => {
predictionTimer = null;
void runPrediction();
}, localDelay);
}
async function runPrediction() {
if (destroyed || !accepted) return;
if (!canPredictLocally(accepted)) return;
const at = estimatedServerNow();
const next = predictNextEntry(
promotedManifest,
predictedEntryId,
at,
[...unavailableEntryIds],
);
if (!next) {
// Nothing locally usable: hold the current good frame and wait for the
// server rather than blanking the screen.
return;
}
predictedEntryId = next.id;
generation += 1;
const token = generation;
const scene = buildScene(next, "normal");
if (scene) await showScene(scene, token, accepted.crossfadeDurationMs ?? 0);
armPrediction(at + (next.effectiveDurationMs ?? 0));
}
// ------------------------------------------------------------------ offline
function anchorFrom(source) {
if (!source) return null;
return {
entryId: source.currentEntryId ?? null,
startedAt: source.timing?.slideStartedAt ?? source.timeline?.slideStartedAt ?? null,
deadlineAt: source.timing?.nextSlideAt ?? source.timeline?.nextSlideAt ?? null,
transportState: source.transportState,
mode: source.mode,
pausedRemainingMs:
source.pausedRemainingMs ?? source.timeline?.pausedRemainingMs ?? null,
staticAssetId: source.staticAssetId ?? source.staticTarget?.assetId ?? null,
staticTarget: source.staticTarget ?? null,
pendingPlaylistId: source.pendingPlaylistId ?? null,
};
}
function clearOfflineTimer() {
if (offlineTimer !== null) {
clearTimeoutFn(offlineTimer);
offlineTimer = null;
}
}
// A locally broken asset must not stall rotation. The timeline itself stays
// authoritative-equivalent; only what is painted is substituted.
function usableEntryFor(entryId, at) {
const entries = [...(promotedManifest.entries || [])].sort(
(left, right) => left.position - right.position,
);
const resolved = entries.find((entry) => entry.id === entryId);
if (resolved && !unavailableEntryIds.has(resolved.id)) return resolved;
const index = entries.findIndex((entry) => entry.id === entryId);
for (let offset = 1; offset <= entries.length; offset += 1) {
const candidate = entries[(Math.max(index, 0) + offset) % entries.length];
if (unavailableEntryIds.has(candidate.id)) continue;
if (
(candidate.scheduleStartAt == null || at >= candidate.scheduleStartAt) &&
(candidate.scheduleEndAt == null || at < candidate.scheduleEndAt) &&
(candidate.expirationAt == null || at < candidate.expirationAt)
) {
return candidate;
}
}
return null;
}
async function renderOffline(resolved, at) {
if (resolved.kind === "hold") {
// Correct-but-held: the next boundary belongs to a playlist this display
// does not have, so the last good frame stays up.
return;
}
if (offlineAnchor?.mode === "off") setGate(true);
if (resolved.kind === "black") {
// The cached playlist genuinely has nothing eligible; black is correct.
if (offlineAnchor?.mode !== "off") hideContent();
renderedTarget = null;
return;
}
if (resolved.kind === "static") {
const target = offlineAnchor?.staticTarget;
if (!target?.asset?.media?.url) {
// Missing local bytes never mean the operator ended static.
recordError(
createDisplayError(DISPLAY_ERROR_KINDS.ASSET_FETCH_FAILED, {
assetId: resolved.assetId,
message: "The held static asset is not available in the local cache",
}),
);
return;
}
generation += 1;
const scene = buildScene(target, "static");
if (scene) await showScene(scene, generation, 0);
return;
}
const entry = usableEntryFor(resolved.entryId, at);
if (!entry) {
// Authoritative content exists but nothing local can render it. The last
// good frame is kept rather than pretending the playlist went black.
recordError(
createDisplayError(DISPLAY_ERROR_KINDS.MANIFEST_INVALID, {
entryId: resolved.entryId,
message: "No cached slide of the active playlist can be rendered offline",
}),
);
return;
}
generation += 1;
const scene = buildScene(entry, "normal");
if (scene) await showScene(scene, generation, accepted?.crossfadeDurationMs ?? 0);
}
async function runOfflineCycle() {
if (destroyed || online || !offlineAnchor) return;
const at = estimatedServerNow();
let resolved;
try {
resolved = resolveOfflineState({ manifest: promotedManifest, anchor: offlineAnchor, at });
} catch (error) {
warnOffline(error);
return;
}
await renderOffline(resolved, at);
if (destroyed || online) return;
const boundary = nextOfflineBoundary({ manifest: promotedManifest, resolved, at });
clearOfflineTimer();
if (boundary === null) return;
offlineTimer = setTimeoutFn(
() => {
offlineTimer = null;
void runOfflineCycle();
},
Math.max(0, boundary - at),
);
diagnostics();
}
function warnOffline(error) {
try {
logger?.warn?.(`Offline playback evaluation failed: ${error?.message}`);
} catch {
// Offline rendering must never depend on logging.
}
}
// The display becomes a deterministic local continuation of the last
// authoritative state. This is resilience, never an attempt to become a
// second authority.
function enterOffline(source) {
if (destroyed) return;
const anchor = anchorFrom(source);
if (!anchor) return;
online = false;
offlineActive = true;
offlineAnchor = anchor;
clearPrediction();
void runOfflineCycle();
}
function exitOffline() {
offlineActive = false;
clearOfflineTimer();
}
// Cold start: restore signage from persisted state before, and without, any
// server contact.
async function bootstrapFromPersistence() {
if (!persistence) return false;
let record;
try {
record = await persistence.readLastKnownGood();
} catch {
record = null;
}
if (!record) return false;
promotedManifest = record.manifest || { entries: [] };
currentError = record.mostRecentError ?? null;
// Reconstruct the authoritative clock from the persisted anchor.
serverOffset = record.timing.serverTime - record.timing.recordedAt;
setGate(record.mode === "off");
enterOffline(record);
return true;
}
// ---------------------------------------------------------------- snapshots
async function applySnapshot(snapshot) {
if (destroyed) return { accepted: false, reason: "destroyed" };
const decision = classifySnapshot(accepted, snapshot);
if (!decision.accept) {
diagnostics();
return { accepted: false, reason: decision.reason };
}
// The server is authoritative again the instant it speaks. Any local
// offline cursor is abandoned rather than reconciled or reported back.
online = true;
exitOffline();
// Packet arrival time is not slide start time; re-anchor on every accepted
// snapshot so local scheduling follows the server's clock. A fresh anchor
// also discards any local clock drift accumulated while disconnected.
serverOffset = snapshot.serverTime - now();
accepted = snapshot;
predictedEntryId = snapshot.currentEntryId ?? null;
clearPrediction();
generation += 1;
const token = generation;
stagingToken += 1;
const staging = stagingToken;
setGate(snapshot.mode === "off");
const target = snapshot.staticTarget
? buildScene(snapshot.staticTarget, "static")
: snapshot.normalTarget
? buildScene(snapshot.normalTarget, "normal")
: null;
if (!target) {
// The server itself says there is nothing playable: black is correct.
if (snapshot.mode !== "off") hideContent();
renderedTarget = null;
} else {
await showScene(target, token, snapshot.crossfadeDurationMs ?? 0);
}
void stageManifest(snapshot, staging);
if (snapshot.timeline?.nextSlideAt != null) {
armPrediction(snapshot.timeline.nextSlideAt);
}
diagnostics();
return { accepted: true, reason: decision.reason };
}
// ---------------------------------------------------------------- heartbeat
async function sendHeartbeat() {
if (destroyed) return null;
let cachedAssetCount;
try {
cachedAssetCount = await assets.count();
} catch {
cachedAssetCount = 0;
}
const entries = promotedManifest.entries || [];
const playableAssetCount = entries.filter(
(entry) => entry.playable && !unavailableEntryIds.has(entry.id),
).length;
const payload = {
protocolVersion: HEARTBEAT_PROTOCOL_VERSION,
clientId,
outputId: accepted?.outputId || "global",
activePlaylistId: accepted?.activePlaylistId ?? null,
playlistRevision: accepted?.manifest?.playlistRevision ?? null,
playbackRevision: accepted?.playbackRevision ?? null,
currentEntryId: renderedTarget?.entryId ?? null,
currentAssetId: renderedTarget?.assetId ?? null,
cachedAssetCount,
playableAssetCount,
mostRecentError: serializeDisplayError(currentError),
};
// Telemetry only. The display never mutates authoritative playback.
socket.emit("display:heartbeat", payload);
return payload;
}
function startHeartbeat() {
if (heartbeatTimer !== null) return;
heartbeatTimer = setIntervalFn(() => {
void sendHeartbeat();
}, heartbeatIntervalMs);
}
function stopHeartbeat() {
if (heartbeatTimer === null) return;
clearIntervalFn(heartbeatTimer);
heartbeatTimer = null;
}
function start() {
socket.on("playback:state", (snapshot) => {
void applySnapshot(snapshot);
});
socket.on("connect", () => {
// A reconnect resynchronizes from the server; it never reloads the page
// and never clears the visible frame. The same persistent client ID is
// reused, so the server sees one display, not a new one.
void sendHeartbeat();
diagnostics();
});
socket.on("disconnect", () => {
// The last good scene stays exactly where it is while local playback
// takes over from the last authoritative state.
clearPrediction();
enterOffline(accepted ?? offlineAnchor);
diagnostics();
});
socket.connect();
startHeartbeat();
}
function destroy() {
destroyed = true;
generation += 1;
stagingToken += 1;
clearPrediction();
clearOfflineTimer();
stopHeartbeat();
socket.off("playback:state");
socket.off("connect");
socket.off("disconnect");
socket.disconnect();
for (const url of layerUrls) if (url) assets.release(url);
layerUrls[0] = null;
layerUrls[1] = null;
}
return {
applySnapshot,
bootstrapFromPersistence,
destroy,
enterOffline,
reportCapabilityLimit,
diagnostics,
estimatedServerNow,
sendHeartbeat,
start,
get state() {
return {
accepted,
currentError,
promotedManifest,
renderedTarget,
serverOffset,
};
},
};
}
+148
View File
@@ -0,0 +1,148 @@
// A deliberately tiny IndexedDB layer: one database, one key-value store, and
// explicit version handling. This is not a general persistence framework, and
// it stays small enough to reason about rather than hiding upgrade footguns
// behind an abstraction.
export const DISPLAY_DB_NAME = "announcements-display";
export const DISPLAY_DB_VERSION = 1;
export const DISPLAY_STORE = "state";
// The record schema is versioned independently of the database so adding
// fields does not require an IndexedDB upgrade. A display carrying an older
// record simply discards it and rebuilds from the server on its next
// successful connection.
export const LKG_SCHEMA_VERSION = 2;
export const STATE_KEYS = Object.freeze({
CLIENT_ID: "clientId",
LAST_KNOWN_GOOD: "lastKnownGood",
SCHEMA: "schema",
});
function request(operation) {
return new Promise((resolve, reject) => {
operation.onsuccess = () => resolve(operation.result);
operation.onerror = () => reject(operation.error);
});
}
export function openDisplayDatabase({
indexedDB: factory = typeof indexedDB === "undefined" ? null : indexedDB,
name = DISPLAY_DB_NAME,
version = DISPLAY_DB_VERSION,
} = {}) {
if (!factory) return Promise.reject(new Error("IndexedDB is unavailable"));
return new Promise((resolve, reject) => {
const open = factory.open(name, version);
open.onupgradeneeded = (event) => {
const database = open.result;
// Every version step is explicit. Version 1 creates the single store.
if (event.oldVersion < 1 && !database.objectStoreNames.contains(DISPLAY_STORE)) {
database.createObjectStore(DISPLAY_STORE);
}
};
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
open.onblocked = () => reject(new Error("IndexedDB upgrade is blocked"));
});
}
export function createDisplayStore({ database }) {
async function read(key) {
const transaction = database.transaction(DISPLAY_STORE, "readonly");
return request(transaction.objectStore(DISPLAY_STORE).get(key));
}
async function write(key, value) {
const transaction = database.transaction(DISPLAY_STORE, "readwrite");
const result = request(transaction.objectStore(DISPLAY_STORE).put(value, key));
await result;
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve(value);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
async function remove(key) {
const transaction = database.transaction(DISPLAY_STORE, "readwrite");
await request(transaction.objectStore(DISPLAY_STORE).delete(key));
}
return { read, write, remove };
}
// Persisted state that survives a refresh, a reconnect, and a Chromium restart.
// Reading is defensive: a partially written or older record is discarded rather
// than allowed to corrupt startup.
export function isValidLastKnownGood(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
if (value.schemaVersion !== LKG_SCHEMA_VERSION) return false;
if (typeof value.playbackRevision !== "number") return false;
if (!value.manifest || typeof value.manifest !== "object") return false;
if (!Array.isArray(value.manifest.entries)) return false;
if (!value.timing || typeof value.timing !== "object") return false;
// Without a usable clock anchor the record cannot drive offline playback.
if (typeof value.timing.serverTime !== "number") return false;
if (typeof value.timing.recordedAt !== "number") return false;
return true;
}
export function createDisplayPersistence({ store, createId, now = Date.now }) {
async function getOrCreateClientId() {
const existing = await store.read(STATE_KEYS.CLIENT_ID);
if (typeof existing === "string" && existing.trim()) return existing;
const clientId = createId();
await store.write(STATE_KEYS.CLIENT_ID, clientId);
return clientId;
}
async function readLastKnownGood() {
let value;
try {
value = await store.read(STATE_KEYS.LAST_KNOWN_GOOD);
} catch {
return null;
}
if (!isValidLastKnownGood(value)) {
// Malformed or superseded records fail safe: the display starts fresh
// from the server rather than trusting an unreadable snapshot.
return null;
}
return value;
}
async function writeLastKnownGood(snapshot, extra = {}) {
const record = {
schemaVersion: LKG_SCHEMA_VERSION,
savedAt: now(),
playbackRevision: snapshot.playbackRevision,
settingsRevision: snapshot.settingsRevision ?? null,
playlistRevision: snapshot.manifest?.playlistRevision ?? null,
activePlaylistId: snapshot.activePlaylistId ?? null,
mode: snapshot.mode,
transportState: snapshot.transportState,
currentEntryId: snapshot.currentEntryId ?? null,
staticAssetId: snapshot.staticTarget?.assetId ?? null,
pendingPlaylistId: snapshot.pendingPlaylistId ?? null,
pausedRemainingMs: snapshot.timeline?.pausedRemainingMs ?? null,
// The timing anchor is what lets a future offline start reason about
// where playback should be, without a server to ask.
timing: {
serverTime: snapshot.serverTime,
slideStartedAt: snapshot.timeline?.slideStartedAt ?? null,
nextSlideAt: snapshot.timeline?.nextSlideAt ?? null,
crossfadeDurationMs: snapshot.crossfadeDurationMs ?? 0,
recordedAt: now(),
},
manifest: snapshot.manifest || { entries: [] },
staticTarget: snapshot.staticTarget || null,
mostRecentError: extra.mostRecentError ?? null,
};
// One record in one transaction: a cold start can never observe a new
// manifest paired with an old timing anchor. The previous complete record
// is only replaced once this one is durably written.
await store.write(STATE_KEYS.LAST_KNOWN_GOOD, record);
return record;
}
return { getOrCreateClientId, readLastKnownGood, writeLastKnownGood };
}
+69
View File
@@ -0,0 +1,69 @@
// Structured display error state. The heartbeat carries the most recent one so
// operations can see why a screen is wrong without opening the kiosk.
export const DISPLAY_ERROR_KINDS = Object.freeze({
SLIDE_DECODE_FAILED: "slide_decode_failed",
ASSET_FETCH_FAILED: "asset_fetch_failed",
CACHE_WRITE_FAILED: "cache_write_failed",
MANIFEST_INVALID: "manifest_invalid",
OFFLINE_CAPABILITY_UNAVAILABLE: "offline_capability_unavailable",
});
const KNOWN_KINDS = new Set(Object.values(DISPLAY_ERROR_KINDS));
const MAX_MESSAGE_LENGTH = 240;
// display_clients.last_error is TEXT, so the serialized form stays well inside
// the server's own bounded-string validation.
export const MAX_SERIALIZED_LENGTH = 480;
function boundedText(value, limit) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > limit ? `${trimmed.slice(0, limit - 1)}…` : trimmed;
}
export function createDisplayError(kind, details = {}) {
if (!KNOWN_KINDS.has(kind)) {
throw new TypeError(`Unknown display error kind: ${kind}`);
}
return {
kind,
assetId: boundedText(details.assetId, 64),
entryId: boundedText(details.entryId, 64),
message: boundedText(details.message, MAX_MESSAGE_LENGTH) || kind,
at: Number.isSafeInteger(details.at) ? details.at : Date.now(),
};
}
// The server accepts a string or a structured object; sending JSON keeps the
// display's error machine-readable without needing a schema migration.
export function serializeDisplayError(error) {
if (!error) return null;
const serialized = JSON.stringify({
kind: error.kind,
assetId: error.assetId ?? null,
entryId: error.entryId ?? null,
message: error.message,
at: error.at,
});
if (serialized.length <= MAX_SERIALIZED_LENGTH) return serialized;
return JSON.stringify({
kind: error.kind,
assetId: error.assetId ?? null,
entryId: error.entryId ?? null,
message: boundedText(error.message, 80) || error.kind,
at: error.at,
});
}
// An error describes a specific asset attempt. Once that asset renders, or the
// entry leaves the authoritative manifest, the condition is genuinely gone.
// A mere passage of time never clears a problem that still exists.
export function shouldClearError(error, { renderedAssetId, manifestAssetIds }) {
if (!error) return false;
if (!error.assetId) return false;
if (error.assetId === renderedAssetId) return true;
if (Array.isArray(manifestAssetIds) && !manifestAssetIds.includes(error.assetId)) {
return true;
}
return false;
}
+297
View File
@@ -0,0 +1,297 @@
import { entryPlayableAt } from "./snapshot.js";
// Deterministic local continuation of the last authoritative state.
//
// This is a small client-side evaluator, intentionally separate from the
// verified Phase 3B server engine: the server stays the single authority while
// connected, and this only reproduces its documented semantics well enough to
// keep a disconnected kiosk correct. Contract tests compare the two directly.
export const TRANSITION_SAFETY_LIMIT = 10_000;
export function entryDuration(entry) {
const value = entry?.effectiveDurationMs;
return Number.isFinite(value) && value > 0 ? value : 0;
}
function ordered(manifest) {
return [...(manifest?.entries || [])].sort(
(left, right) => left.position - right.position,
);
}
function boundariesOf(entry) {
return [entry.scheduleStartAt, entry.scheduleEndAt, entry.expirationAt];
}
// The first instant in (after, through] at which any entry's eligibility can
// change. Between such instants the eligible set is constant, which is what
// makes whole-cycle skipping safe.
function firstEligibilityBoundary(entries, after, through) {
let best = null;
for (const entry of entries) {
for (const value of boundariesOf(entry)) {
if (typeof value !== "number") continue;
if (value > after && value <= through && (best === null || value < best)) {
best = value;
}
}
}
return best;
}
// A currently displayed entry can be cut short by its own schedule end or asset
// expiration, never by another entry's boundary.
function invalidBoundaryFor(entry, startedAt, through) {
if (!entry) return null;
let best = null;
for (const value of [entry.scheduleEndAt, entry.expirationAt]) {
if (typeof value !== "number") continue;
if (value > startedAt && value <= through && (best === null || value < best)) {
best = value;
}
}
return best;
}
function firstPlayable(entries, at) {
return entries.find((entry) => entryPlayableAt(entry, at)) || null;
}
function adjacentPlayable(entries, currentId, at) {
if (entries.length === 0) return null;
const index = entries.findIndex((entry) => entry.id === currentId);
if (index < 0) return firstPlayable(entries, at);
for (let offset = 1; offset <= entries.length; offset += 1) {
const candidate = entries[(index + offset) % entries.length];
if (entryPlayableAt(candidate, at)) return candidate;
}
return null;
}
// The next instant at which a currently ineligible playlist becomes playable
// again, used to recover from a legitimately empty stretch.
function nextAvailability(entries, after, through) {
const starts = entries
.map((entry) => entry.scheduleStartAt)
.filter((value) => typeof value === "number" && value > after && value <= through)
.sort((left, right) => left - right);
return starts.find((candidate) => entries.some((entry) => entryPlayableAt(entry, candidate))) ?? null;
}
// Skips whole rotations with arithmetic instead of stepping one slide at a
// time, so a kiosk offline for a week catches up in constant work per
// eligibility boundary rather than per missed slide.
function fastForward(entries, cursor, at) {
const boundary = firstEligibilityBoundary(entries, cursor.startedAt, at);
const through = boundary === null ? at : boundary - 1;
if (cursor.deadlineAt > through) return false;
const eligible = entries.filter((entry) => entryPlayableAt(entry, through));
const index = eligible.findIndex((entry) => entry.id === cursor.entryId);
if (index < 0 || eligible.length === 0) return false;
const cycleDuration = eligible.reduce((total, entry) => total + entryDuration(entry), 0);
if (cycleDuration <= 0) return false;
let position = (index + 1) % eligible.length;
let startedAt = cursor.deadlineAt;
const cycles = Math.floor((through - startedAt) / cycleDuration);
if (cycles > 0) startedAt += cycles * cycleDuration;
let deadlineAt = startedAt + entryDuration(eligible[position]);
while (deadlineAt <= through) {
startedAt = deadlineAt;
position = (position + 1) % eligible.length;
deadlineAt = startedAt + entryDuration(eligible[position]);
}
cursor.entryId = eligible[position].id;
cursor.startedAt = startedAt;
cursor.deadlineAt = deadlineAt;
return boundary === null;
}
// Walks the rotation forward from an anchor to `at`, honouring schedule and
// expiration boundaries exactly as the server does.
function advanceRotation(entries, anchor, at) {
const cursor = {
entryId: anchor.entryId,
startedAt: anchor.startedAt,
deadlineAt: anchor.deadlineAt,
};
for (let step = 0; step < TRANSITION_SAFETY_LIMIT; step += 1) {
let current = entries.find((entry) => entry.id === cursor.entryId);
if (!current) {
const availableAt = nextAvailability(
entries,
Number.isFinite(cursor.startedAt) ? cursor.startedAt : at,
at,
);
const startAt = availableAt ?? at;
const replacement = firstPlayable(entries, startAt);
if (!replacement) return { kind: "black", reason: "no_playable_content" };
cursor.entryId = replacement.id;
cursor.startedAt = startAt;
cursor.deadlineAt = startAt + entryDuration(replacement);
continue;
}
if (cursor.deadlineAt <= at && fastForward(entries, cursor, at)) {
current = entries.find((entry) => entry.id === cursor.entryId);
}
if (cursor.entryId !== current?.id) continue;
const invalidAt = invalidBoundaryFor(current, cursor.startedAt, at);
const playableNow = entryPlayableAt(current, at);
if (playableNow && cursor.deadlineAt > at) {
return {
kind: "normal",
entryId: cursor.entryId,
startedAt: cursor.startedAt,
deadlineAt: cursor.deadlineAt,
};
}
let boundary = cursor.deadlineAt <= at ? cursor.deadlineAt : at;
if (invalidAt !== null) boundary = Math.min(boundary, invalidAt);
const next = adjacentPlayable(entries, cursor.entryId, boundary);
if (!next) {
const availableAt = nextAvailability(entries, boundary, at);
if (availableAt === null) {
const now = firstPlayable(entries, at);
if (!now) return { kind: "black", reason: "no_playable_content" };
return {
kind: "normal",
entryId: now.id,
startedAt: at,
deadlineAt: at + entryDuration(now),
};
}
const replacement = firstPlayable(entries, availableAt);
cursor.entryId = replacement.id;
cursor.startedAt = availableAt;
cursor.deadlineAt = availableAt + entryDuration(replacement);
continue;
}
cursor.entryId = next.id;
cursor.startedAt = boundary;
cursor.deadlineAt = boundary + entryDuration(next);
}
throw new Error("Offline catch-up exceeded its transition safety limit");
}
function staticStillValid(staticTarget, at) {
if (!staticTarget) return false;
const expiration = staticTarget.expirationAt ?? staticTarget.asset?.expirationAt ?? null;
// A null expiration is permanent; local age never invents one.
if (typeof expiration !== "number") return true;
return at < expiration;
}
function heldEntryAt(entries, anchor, at) {
const current = entries.find((entry) => entry.id === anchor.entryId);
if (current && entryPlayableAt(current, at)) return current;
// A held slide can still be invalidated by schedule or expiration; the
// replacement is held in turn rather than starting to advance.
return adjacentPlayable(entries, anchor.entryId, at);
}
/**
* Resolves what a disconnected display should be showing at `at`, from the last
* authoritative state it persisted. Pure and deterministic.
*/
export function resolveOfflineState({ manifest, anchor, at }) {
const entries = ordered(manifest);
if (anchor.pendingPlaylistId) {
// The next boundary belongs to a playlist this display does not have. Going
// deeper into the old playlist would show known-wrong content, so the last
// good frame is held until the server can speak again.
if (typeof anchor.deadlineAt === "number" && at >= anchor.deadlineAt) {
return { kind: "hold", reason: "pending_activation_unknown" };
}
}
if (anchor.staticAssetId) {
if (staticStillValid(anchor.staticTarget, at)) {
return { kind: "static", assetId: anchor.staticAssetId };
}
// A genuinely expired static asset releases the hold and normal playback
// resumes from that exact instant.
const expiration =
anchor.staticTarget?.expirationAt ?? anchor.staticTarget?.asset?.expirationAt ?? at;
const resumeAt = Math.min(at, expiration);
const current = entries.find((entry) => entry.id === anchor.entryId);
const full = entryDuration(current);
const remaining = Math.min(
typeof anchor.pausedRemainingMs === "number" ? anchor.pausedRemainingMs : full,
full,
);
const deadlineAt = resumeAt + remaining;
return advanceRotation(
entries,
{ entryId: anchor.entryId, startedAt: deadlineAt - full, deadlineAt },
at,
);
}
if (anchor.transportState === "paused") {
const held = heldEntryAt(entries, anchor, at);
if (!held) return { kind: "black", reason: "no_playable_content" };
return {
kind: "paused",
entryId: held.id,
remainingMs:
held.id === anchor.entryId && typeof anchor.pausedRemainingMs === "number"
? Math.min(anchor.pausedRemainingMs, entryDuration(held))
: entryDuration(held),
};
}
if (entries.length === 0) return { kind: "black", reason: "no_playable_content" };
if (typeof anchor.deadlineAt !== "number" || typeof anchor.startedAt !== "number") {
const now = firstPlayable(entries, at);
if (!now) return { kind: "black", reason: "no_playable_content" };
return {
kind: "normal",
entryId: now.id,
startedAt: at,
deadlineAt: at + entryDuration(now),
};
}
return advanceRotation(
entries,
{ entryId: anchor.entryId, startedAt: anchor.startedAt, deadlineAt: anchor.deadlineAt },
at,
);
}
/**
* The next instant local playback must re-evaluate: the current slide's
* deadline, or the next cached eligibility boundary when nothing is playable.
*/
export function nextOfflineBoundary({ manifest, resolved, at }) {
const entries = ordered(manifest);
const candidates = [];
if (resolved?.kind === "normal" && typeof resolved.deadlineAt === "number") {
candidates.push(resolved.deadlineAt);
}
for (const entry of entries) {
for (const value of boundariesOf(entry)) {
if (typeof value === "number" && value > at) candidates.push(value);
}
}
if (resolved?.kind === "static") {
const expiration =
resolved.expirationAt ?? resolved.staticExpirationAt ?? null;
if (typeof expiration === "number" && expiration > at) candidates.push(expiration);
}
return candidates.length > 0 ? Math.min(...candidates) : null;
}
+70
View File
@@ -0,0 +1,70 @@
// A true 16:9 slide fills the frame. Everything else is shown whole on a
// background. The decision comes from the manifest's real pixel dimensions,
// never from a filename or a resolution label.
export const ASPECT_16_9 = 16 / 9;
export const ASPECT_TOLERANCE = 0.02;
export const DEFAULT_BLUR_PX = 20;
export const DARKENING_OPACITY = 0.25;
export function isSixteenByNine(width, height) {
if (!Number.isFinite(width) || !Number.isFinite(height)) return false;
if (width <= 0 || height <= 0) return false;
return Math.abs(width / height - ASPECT_16_9) <= ASPECT_TOLERANCE;
}
function normalizeBackgroundMode(mode) {
return mode === "solid" || mode === "black" || mode === "blurred"
? mode
: "blurred";
}
function normalizeColor(value) {
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value.trim())
? value.trim()
: "#000000";
}
function normalizeBlur(value) {
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_BLUR_PX;
}
// Resolves one authoritative target into everything a scene layer needs. The
// server has already resolved entry overrides against global defaults, so this
// only decides how those effective values are painted.
export function resolveScenePresentation(target) {
const media = target?.asset?.media || {};
const presentation = target?.effectivePresentation || {};
const fills = isSixteenByNine(media.width, media.height);
const backgroundMode = normalizeBackgroundMode(presentation.backgroundMode);
const darkened = Boolean(presentation.backgroundDarkened);
if (fills) {
// A correct 16:9 slide covers the whole frame on its own: no blurred
// duplicate, no pillarbox, and no floating-card shadow.
return {
fit: "fill",
backgroundMode: "none",
backgroundColor: "#000000",
backgroundImage: false,
blurPx: 0,
darkened: false,
darkenOpacity: 0,
shadow: false,
};
}
return {
fit: "contain",
backgroundMode,
backgroundColor: normalizeColor(presentation.backgroundColor),
backgroundImage: backgroundMode === "blurred",
blurPx: backgroundMode === "blurred" ? normalizeBlur(presentation.blurPx) : 0,
darkened,
darkenOpacity: darkened
? Number.isFinite(presentation.backgroundDarkeningOpacity)
? presentation.backgroundDarkeningOpacity
: DARKENING_OPACITY
: 0,
shadow: true,
};
}
+62
View File
@@ -0,0 +1,62 @@
/* Shared scene presentation.
Both the kiosk renderer and the dashboard's live preview import this, so a
slide cannot look one way on the signage output and another way in
management. */
/* Two permanent layers. Only opacity and content change; they are never
recreated, and an outgoing layer is never faded out. */
.scene {
position: absolute;
inset: 0;
opacity: 0;
background: #000;
transition-property: opacity;
transition-timing-function: linear;
transition-duration: 0ms;
will-change: opacity;
}
.scene-background {
position: absolute;
inset: 0;
background-color: #000;
overflow: hidden;
}
/* Overscanned so the blur never reveals a transparent or black edge. */
.scene-background-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
transform: scale(1.08);
display: none;
}
.scene-darken {
position: absolute;
inset: 0;
background: #000;
opacity: 0;
}
.scene-foreground {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-position: center;
}
/* A preview renders the same scene inside a small box. Blur is expressed in
pixels, so it is scaled down with the container to keep the preview
visually representative of the full-size output. */
.scene-preview {
position: relative;
overflow: hidden;
background: #000;
aspect-ratio: 16 / 9;
width: 100%;
}
+107
View File
@@ -0,0 +1,107 @@
// One persistent full-screen scene layer.
//
// The v1 renderer recreated image elements per slide and cross-faded the
// outgoing frame to transparent, which let the black root show through the
// composite. These layers are created once and never rebuilt; only their
// contents and opacity change, and an outgoing layer is never faded out.
export function createSceneLayer(root) {
const background = root.querySelector(".scene-background");
const backgroundImage = root.querySelector(".scene-background-image");
const darken = root.querySelector(".scene-darken");
const foreground = root.querySelector(".scene-foreground");
let sceneKey = null;
let foregroundUrl = null;
let backgroundUrl = null;
function setStacking(zIndex) {
root.style.zIndex = String(zIndex);
}
function setOpacity(value, transitionMs) {
root.style.transitionDuration = `${Math.max(0, transitionMs)}ms`;
root.style.opacity = String(value);
root.dataset.visible = value >= 1 ? "true" : "false";
}
function forceReflow() {
// Guarantees the browser observes opacity 0 before the transition starts,
// so the incoming layer actually animates instead of snapping.
void root.offsetHeight;
}
// Applies a fully prepared scene and resolves only once the browser can
// actually paint the image. A freshly assigned src is never treated as ready.
async function prepare(scene) {
root.dataset.sceneKey = scene.key;
root.dataset.fit = scene.presentation.fit;
root.dataset.backgroundMode = scene.presentation.backgroundMode;
root.dataset.darkened = scene.presentation.darkened ? "true" : "false";
root.dataset.shadow = scene.presentation.shadow ? "true" : "false";
background.style.backgroundColor =
scene.presentation.backgroundMode === "solid"
? scene.presentation.backgroundColor
: "#000000";
if (scene.presentation.backgroundImage) {
backgroundImage.style.display = "block";
backgroundImage.style.filter = `blur(${scene.presentation.blurPx}px)`;
if (backgroundImage.getAttribute("src") !== scene.objectUrl) {
backgroundImage.setAttribute("src", scene.objectUrl);
}
} else {
backgroundImage.style.display = "none";
backgroundImage.removeAttribute("src");
}
darken.style.opacity = String(scene.presentation.darkenOpacity);
foreground.style.objectFit =
scene.presentation.fit === "fill" ? "cover" : "contain";
foreground.style.filter = scene.presentation.shadow
? "drop-shadow(0 0.6vh 1.6vh rgba(0, 0, 0, 0.45))"
: "none";
if (foreground.getAttribute("src") !== scene.objectUrl) {
foreground.setAttribute("src", scene.objectUrl);
}
// The element that will actually be composited must be decodable, not just
// present in the DOM.
await foreground.decode();
if (scene.presentation.backgroundImage) {
await backgroundImage.decode();
}
sceneKey = scene.key;
foregroundUrl = scene.mediaUrl;
backgroundUrl = scene.presentation.backgroundImage ? scene.mediaUrl : null;
}
function clear() {
root.dataset.sceneKey = "";
foreground.removeAttribute("src");
backgroundImage.removeAttribute("src");
sceneKey = null;
foregroundUrl = null;
backgroundUrl = null;
}
return {
clear,
element: root,
forceReflow,
get backgroundUrl() {
return backgroundUrl;
},
get mediaUrl() {
return foregroundUrl;
},
get sceneKey() {
return sceneKey;
},
prepare,
setOpacity,
setStacking,
};
}
+128
View File
@@ -0,0 +1,128 @@
// Snapshot acceptance and local rotation prediction.
//
// The server is always authoritative. These helpers only decide whether an
// arriving snapshot is newer than what is already accepted, and what the
// display would expect to happen next so transitions stay smooth between
// packets.
export function manifestSignature(snapshot) {
const entries = snapshot?.manifest?.entries || [];
return [
snapshot?.manifest?.playlistId ?? "none",
snapshot?.manifest?.playlistRevision ?? 0,
entries
.map((entry) =>
[
entry.id,
entry.position,
entry.assetId,
entry.asset?.media?.sha256 ?? "",
entry.effectiveDurationMs,
entry.scheduleStartAt ?? "",
entry.scheduleEndAt ?? "",
entry.effectivePresentation?.backgroundMode ?? "",
entry.effectivePresentation?.backgroundColor ?? "",
entry.effectivePresentation?.blurPx ?? "",
entry.effectivePresentation?.backgroundDarkened ? 1 : 0,
].join(":"),
)
.join("|"),
].join("#");
}
// Playback revision alone is not enough. A playlist edit, a settings change, or
// a schedule boundary can produce a manifest-relevant snapshot while the
// playback revision legitimately stays the same, so an equal revision is never
// treated as a duplicate on its own.
export function classifySnapshot(accepted, next) {
if (!next || typeof next.playbackRevision !== "number") {
return { accept: false, reason: "invalid" };
}
if (!accepted) return { accept: true, reason: "first" };
if (next.playbackRevision < accepted.playbackRevision) {
return { accept: false, reason: "stale_playback_revision" };
}
if (next.playbackRevision > accepted.playbackRevision) {
return { accept: true, reason: "playback_revision" };
}
if ((next.settingsRevision ?? 0) !== (accepted.settingsRevision ?? 0)) {
return { accept: true, reason: "settings_revision" };
}
if (manifestSignature(next) !== manifestSignature(accepted)) {
return { accept: true, reason: "manifest" };
}
if (targetKey(next) !== targetKey(accepted)) {
return { accept: true, reason: "target" };
}
if (
next.mode !== accepted.mode ||
next.transportState !== accepted.transportState ||
(next.pendingPlaylistId ?? null) !== (accepted.pendingPlaylistId ?? null)
) {
return { accept: true, reason: "transport" };
}
// Identical authoritative state: still worth re-anchoring the clock, but no
// visual work is implied.
return { accept: true, reason: "reanchor", unchanged: true };
}
export function targetKey(snapshot) {
if (!snapshot) return "none";
if (snapshot.mode === "off") return "black:mode_off";
if (snapshot.staticTarget) {
return `static:${snapshot.staticTarget.assetId}:${
snapshot.staticTarget.asset?.media?.sha256 ?? ""
}`;
}
if (snapshot.normalTarget) {
return `normal:${snapshot.normalTarget.id}:${
snapshot.normalTarget.asset?.media?.sha256 ?? ""
}`;
}
return "black:no_playable_content";
}
// Schedules are [start, end): active exactly at start, inactive exactly at end.
export function entryPlayableAt(entry, at) {
if (!entry) return false;
if (entry.scheduleStartAt != null && at < entry.scheduleStartAt) return false;
if (entry.scheduleEndAt != null && at >= entry.scheduleEndAt) return false;
if (entry.expirationAt != null && at >= entry.expirationAt) return false;
return true;
}
function orderedEntries(manifest) {
return [...(manifest?.entries || [])].sort(
(left, right) => left.position - right.position,
);
}
// Mirrors the server's adjacent-playable rotation so a predicted transition
// lands on the same entry the server will later confirm.
export function predictNextEntry(manifest, currentEntryId, at, unavailableIds = []) {
const entries = orderedEntries(manifest);
if (entries.length === 0) return null;
const usable = (entry) =>
entryPlayableAt(entry, at) && !unavailableIds.includes(entry.id);
const index = entries.findIndex((entry) => entry.id === currentEntryId);
if (index < 0) return entries.find(usable) || null;
for (let offset = 1; offset <= entries.length; offset += 1) {
const candidate = entries[(index + offset) % entries.length];
if (usable(candidate)) return candidate;
}
return null;
}
// A queued activation means the very next boundary belongs to a playlist the
// display may not have yet. Predicting another slide from the old playlist
// would briefly show the wrong content, so prediction is withheld instead.
export function canPredictLocally(snapshot) {
if (!snapshot) return false;
if (snapshot.transportState !== "playing") return false;
if (snapshot.staticTarget) return false;
if (!snapshot.normalTarget) return false;
if (typeof snapshot.timeline?.nextSlideAt !== "number") return false;
if (snapshot.pendingPlaylistId) return false;
return true;
}
+3 -5
View File
@@ -1,3 +1,5 @@
/* Neutral global baseline. Signage-specific fullscreen rules are scoped to the
display route in display.css so the future dashboard is not constrained. */
* {
box-sizing: border-box;
}
@@ -7,12 +9,8 @@ body,
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: black;
overflow: hidden;
}
body {
display: block;
min-height: 100%;
}
+21 -9
View File
@@ -1,10 +1,22 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import DashboardApp from "./dashboard/DashboardApp.jsx";
import DisplayApp from "./display/DisplayApp.jsx";
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
const isDisplayRoute = window.location.pathname.replace(/\/+$/, "") === "/display";
// The service worker only backs the signage app shell. Slide bytes are owned by
// the explicit Cache Storage asset manager, never by the worker.
if (isDisplayRoute && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/display-sw.js", { scope: "/" }).catch(() => {
// A kiosk without a worker still runs; it just loses offline app-shell
// startup until the next successful registration.
});
});
}
createRoot(document.getElementById("root")).render(
<StrictMode>{isDisplayRoute ? <DisplayApp /> : <DashboardApp />}</StrictMode>,
);
+18 -5
View File
@@ -1,7 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => {
const environment = loadEnv(mode, ".", "VITE_");
const target = environment.VITE_API_PROXY_TARGET || "http://127.0.0.1:7532";
const proxy = { target, changeOrigin: true };
return {
plugins: [react()],
})
server: {
proxy: {
"/api": proxy,
"/health": proxy,
"/media": proxy,
"/socket.io": { ...proxy, ws: true },
},
},
};
});
+118 -14
View File
@@ -1,20 +1,124 @@
name: "${COMPOSE_PROJECT_NAME:-announcements-v2}"
services:
announcements-server:
web:
build:
context: .
dockerfile: client/Dockerfile
restart: unless-stopped
init: true
ports:
- "${WEB_PORT:-5175}:80"
depends_on:
api:
condition: service_healthy
networks:
app:
# Only the web service is reachable from the shared reverse-proxy
# network. Caddy resolves it as announcements-web:80; the API and object
# storage stay private on the Announcements networks.
tabernet:
aliases:
- announcements-web
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
api:
build:
context: .
dockerfile: server/Dockerfile
container_name: announcements-server
restart: unless-stopped
ports:
- "7532:7532"
announcements-client:
build:
context: ./client
dockerfile: Dockerfile
container_name: announcements-client
restart: unless-stopped
ports:
- "5174:80"
init: true
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: "7532"
DATABASE_PATH: /data/db/announcements.sqlite
APP_TIMEZONE: "${APP_TIMEZONE:-America/Chicago}"
SHUTDOWN_TIMEOUT_MS: "${SHUTDOWN_TIMEOUT_MS:-10000}"
DATABASE_BUSY_TIMEOUT_MS: "${DATABASE_BUSY_TIMEOUT_MS:-5000}"
TRUST_PROXY: "${TRUST_PROXY:-true}"
S3_ENDPOINT: http://seaweedfs:8333
S3_REGION: "${S3_REGION:-us-east-1}"
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
S3_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
S3_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
S3_FORCE_PATH_STYLE: "true"
S3_CONNECT_TIMEOUT_MS: "${S3_CONNECT_TIMEOUT_MS:-2000}"
S3_REQUEST_TIMEOUT_MS: "${S3_REQUEST_TIMEOUT_MS:-5000}"
S3_MAX_ATTEMPTS: "${S3_MAX_ATTEMPTS:-2}"
S3_HEALTH_PROBE_INTERVAL_MS: "${S3_HEALTH_PROBE_INTERVAL_MS:-30000}"
UPLOAD_TEMP_DIRECTORY: /tmp/announcements-uploads
UPLOAD_MAX_FILE_BYTES: "${UPLOAD_MAX_FILE_BYTES:-26214400}"
UPLOAD_MAX_FILES: "${UPLOAD_MAX_FILES:-20}"
IMAGE_MAX_INPUT_PIXELS: "${IMAGE_MAX_INPUT_PIXELS:-40000000}"
MQTT_URL: "${MQTT_URL:-}"
MQTT_USERNAME: "${MQTT_USERNAME:-}"
MQTT_PASSWORD: "${MQTT_PASSWORD:-}"
MQTT_TOPIC_PREFIX: "${MQTT_TOPIC_PREFIX:-announcements}"
MQTT_RECONNECT_PERIOD_MS: "${MQTT_RECONNECT_PERIOD_MS:-5000}"
MQTT_CONNECT_TIMEOUT_MS: "${MQTT_CONNECT_TIMEOUT_MS:-10000}"
HA_WEBHOOK_URL: "${HA_WEBHOOK_URL:-}"
HA_WEBHOOK_SECRET: "${HA_WEBHOOK_SECRET:-}"
HA_WEBHOOK_TIMEOUT_MS: "${HA_WEBHOOK_TIMEOUT_MS:-10000}"
DISPLAY_OFFLINE_THRESHOLD_MS: "${DISPLAY_OFFLINE_THRESHOLD_MS:-45000}"
volumes:
- app-data:/data
depends_on:
- announcements-server
seaweedfs:
condition: service_started
networks:
- app
- storage
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:7532/health/ready').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
stop_grace_period: 15s
seaweedfs:
image: chrislusf/seaweedfs:4.39
restart: unless-stopped
command: ["mini", "-dir=/data", "-ip=seaweedfs"]
environment:
AWS_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
AWS_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
volumes:
- seaweedfs-data:/data
expose:
- "8333"
networks:
- storage
healthcheck:
test:
- CMD-SHELL
- >-
wget -q -O /dev/null http://127.0.0.1:9333/readyz &&
wget -q -O /dev/null http://127.0.0.1:8333/readyz
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
stop_grace_period: 30s
networks:
app:
storage:
internal: true
tabernet:
external: true
volumes:
app-data:
seaweedfs-data:
+14 -19
View File
@@ -1,24 +1,19 @@
{
"name": "Annoucements",
"version": "1.0.0",
"description": "",
"main": "index.js",
"name": "announcements",
"version": "2.0.0",
"private": true,
"description": "Church announcement management and signage",
"scripts": {
"dev": "nodemon server/index.js",
"start": "node server/index.js"
"dev:server": "pnpm --filter @announcements/server dev",
"dev:client": "pnpm --filter @announcements/client dev",
"start": "pnpm --filter @announcements/server start",
"build": "pnpm --filter @announcements/client build",
"lint": "pnpm --filter @announcements/client lint",
"test": "pnpm --filter @announcements/server test",
"check": "pnpm lint && pnpm test && pnpm build"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "[email protected]",
"dependencies": {
"cors": "^2.8.6",
"express": "^5.2.1",
"fast-xml-parser": "^5.8.0",
"mqtt": "^5.15.1",
"socket.io": "^4.8.3"
"engines": {
"node": ">=22.13.0"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
"packageManager": "[email protected]"
}
+2262 -243
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
packages:
- client
- server
onlyBuiltDependencies:
- better-sqlite3
- sharp
+24 -5
View File
@@ -1,13 +1,32 @@
FROM node:22-alpine
FROM node:22.23.2-bookworm-slim AS dependencies
ARG PNPM_VERSION=10.29.3
WORKDIR /app
RUN corepack enable
RUN apt-get update \
&& apt-get install --yes --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY server/package.json ./server/package.json
COPY client/package.json ./client/package.json
RUN pnpm install --frozen-lockfile --prod --filter @announcements/server...
COPY server ./server
FROM node:22.23.2-bookworm-slim
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=dependencies /app/server ./server
ENV NODE_ENV=production
EXPOSE 7532
CMD ["node", "server/index.js"]
CMD ["node", "server/src/index.js"]
-131
View File
@@ -1,131 +0,0 @@
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const cors = require("cors");
const app = express();
app.use(cors());
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
},
});
const PORT = 7532;
let currentSlide = 0;
const slideSeconds = 7;
const { XMLParser } = require("fast-xml-parser");
let slides = [];
async function loadSlides() {
try {
const response = await fetch(
"http://10.40.0.2:9000/announcements?list-type=2"
);
const xml = await response.text();
const parser = new XMLParser();
const data = parser.parse(xml);
const contents = data.ListBucketResult?.Contents || [];
const files = Array.isArray(contents)
? contents
: [contents];
slides = files
.map((file) => file.Key)
.filter((name) =>
/\.(png|jpg|jpeg|webp)$/i.test(name)
)
.map((name) => {
const encodedName = name
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
return `http://10.40.0.2:9000/announcements/${encodedName}`;
});
console.log("Slides loaded:", slides.length);
if (currentSlide >= slides.length) {
currentSlide = 0;
}
broadcastState();
} catch (err) {
console.error("Failed to load slides:", err);
}
}
const mqtt = require("mqtt");
let mode = "on";
const mqttClient = mqtt.connect("mqtt://10.40.0.2");
mqttClient.on("connect", () => {
console.log("MQTT connected");
mqttClient.subscribe("announcements/mode");
});
mqttClient.on("message", (topic, message) => {
if (topic === "announcements/mode") {
const value = message.toString().trim().toLowerCase();
if (value === "on" || value === "off") {
mode = value;
broadcastState();
console.log("Announcement mode:", mode);
}
}
});
function broadcastState() {
io.emit("slideshow:state", {
currentSlide,
slides,
slideSeconds,
mode,
});
}
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
socket.emit("slideshow:state", {
currentSlide,
slides,
slideSeconds,
mode,
});
});
setInterval(() => {
if (slides.length === 0) return;
currentSlide = (currentSlide + 1) % slides.length;
broadcastState();
}, slideSeconds * 1000);
app.get("/", (req, res) => {
res.send("Announcement server is running");
});
loadSlides();
setInterval(() => {
loadSlides();
}, 30000);
server.listen(PORT, () => {
console.log(`Announcement server running on http://10.40.0.2:${PORT}`);
});
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@announcements/server",
"version": "2.0.0",
"private": true,
"description": "Announcements v2 API and realtime server",
"main": "src/index.js",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"engines": {
"node": ">=22.13.0"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1101.0",
"better-sqlite3": "13.0.2",
"dotenv": "^16.5.0",
"express": "^5.2.1",
"mqtt": "5.15.2",
"multer": "2.0.2",
"sharp": "0.35.3",
"socket.io": "^4.8.3"
}
}
+198
View File
@@ -0,0 +1,198 @@
const express = require("express");
const {
createDetailedHealthRouter,
createHealthRouter,
} = require("./health/routes");
const { createAssetRouter } = require("./assets/routes");
const { createMediaRouter } = require("./assets/media-routes");
const { createAssetService } = require("./assets/service");
const { createDisplayRouter } = require("./displays/routes");
const { createPlaybackService } = require("./playback");
const { createPlaybackRouter } = require("./playback/routes");
const {
createPlaylistRouter,
createPlaylistService,
} = require("./playlists");
const { createSettingsRouter } = require("./settings/routes");
const { createSettingsService } = require("./settings/service");
function safeNotifier(notifier, logger, failureMessage) {
const warn = (error) => {
try {
logger?.warn?.(failureMessage, error);
} catch {
// A best-effort notification or warning must not undo a database commit.
}
};
return (event) => {
try {
const result = notifier(event);
result?.catch?.(warn);
} catch (error) {
warn(error);
}
};
}
function createApp({
config,
database,
displayService,
imageProcessor,
integrations = {},
lifecycle,
logger = console,
migrations,
now = Date.now,
onAssetProcessingFailed = () => {},
onPlaylistsChanged = () => {},
playbackService: suppliedPlaybackService,
storage,
}) {
const app = express();
const notifyPlaylistsChanged = safeNotifier(
onPlaylistsChanged,
logger,
"Playlist invalidation notification failed",
);
const playbackService =
suppliedPlaybackService ||
createPlaybackService({
database,
now,
onActivationChanged: notifyPlaylistsChanged,
});
if (!suppliedPlaybackService) {
playbackService.initialize();
}
const reconcilePlaybackDomain = (change) =>
playbackService.domainChanged(change);
const reconcileBeforeDomainChange = () =>
playbackService.reconcile("before_domain_change");
const notifyPlaylistMutation = (change) => {
reconcilePlaybackDomain(change);
const publicChange = { ...change };
delete publicChange.invalidBoundaryChanged;
notifyPlaylistsChanged(publicChange);
};
const settingsService = createSettingsService(database);
const notifyAssetProcessingFailed = safeNotifier(
onAssetProcessingFailed,
logger,
"Asset processing failure notification failed",
);
const assetService = createAssetService({
database,
imageProcessor,
onProcessingFailed: notifyAssetProcessingFailed,
storage,
});
const playlistService = createPlaylistService({
database,
now,
onBeforeChange: reconcileBeforeDomainChange,
onChanged: notifyPlaylistMutation,
});
app.locals.playbackService = playbackService;
app.disable("x-powered-by");
app.set("trust proxy", config.trustProxy);
app.use(express.json({ limit: "1mb" }));
app.get("/api", (request, response) => {
response.json({ name: "Announcements API", version: 2 });
});
app.use(
"/health",
createHealthRouter({ database, lifecycle, migrations, storage }),
);
const detailedHealthRouterOptions = {
config,
database,
displayService,
integrations,
lifecycle,
migrations,
playbackService,
storage,
};
app.use(
"/api/health",
createDetailedHealthRouter(detailedHealthRouterOptions),
);
app.use(
"/api/status",
createDetailedHealthRouter(detailedHealthRouterOptions),
);
app.use(
"/api/settings",
createSettingsRouter(settingsService, {
onBeforePlaybackDomainChange: reconcileBeforeDomainChange,
onPlaylistsChanged: notifyPlaylistsChanged,
onPlaybackDomainChanged: reconcilePlaybackDomain,
}),
);
app.use(
"/api/assets",
createAssetRouter({
assetService,
onBeforePlaybackDomainChange: reconcileBeforeDomainChange,
onPlaylistsChanged: notifyPlaylistsChanged,
onPlaybackDomainChanged: reconcilePlaybackDomain,
processingConfig: config.processing,
}),
);
app.use(
"/api/playlists",
createPlaylistRouter(playlistService, {
playbackService,
}),
);
app.use("/api/playback", createPlaybackRouter(playbackService));
if (displayService) {
app.use("/api/displays", createDisplayRouter(displayService));
}
app.use("/media", createMediaRouter(assetService));
app.use((request, response) => {
response.status(404).json({
error: {
code: "NOT_FOUND",
message: "Route not found",
},
});
});
app.use((error, request, response, next) => {
if (response.headersSent) {
next(error);
return;
}
if (error instanceof SyntaxError && error.status === 400 && "body" in error) {
response.status(400).json({
error: {
code: "INVALID_JSON",
message: "Request body must contain valid JSON",
},
});
return;
}
const statusCode = error.statusCode || 500;
response.status(statusCode).json({
error: {
code: error.code || "INTERNAL_ERROR",
message:
statusCode >= 500 ? "An internal server error occurred" : error.message,
...(error.details ? { details: error.details } : {}),
},
});
});
return app;
}
module.exports = {
createApp,
};
+23
View File
@@ -0,0 +1,23 @@
class AssetError extends Error {
constructor(code, message, { statusCode = 400, details, cause } = {}) {
super(message, cause ? { cause } : undefined);
this.name = "AssetError";
this.code = code;
this.statusCode = statusCode;
if (details !== undefined) {
this.details = details;
}
}
}
function assetNotFound(assetId) {
return new AssetError("ASSET_NOT_FOUND", "Library asset not found", {
statusCode: 404,
details: { assetId },
});
}
module.exports = {
AssetError,
assetNotFound,
};
+49
View File
@@ -0,0 +1,49 @@
const express = require("express");
const { sendImmutableMedia } = require("./streaming");
function createMediaRouter(assetService) {
const router = express.Router();
async function sendMedia(request, response, next, kind) {
const version = request.params.version;
if (!version.endsWith(".webp")) {
response.status(404).json({
error: {
code: "MEDIA_NOT_FOUND",
message: "Asset media version not found",
},
});
return;
}
try {
const descriptor = assetService.mediaDescriptor(
request.params.assetId,
kind,
version.slice(0, -".webp".length),
);
await sendImmutableMedia(assetService, response, descriptor);
} catch (error) {
if (response.headersSent) {
response.destroy(error);
return;
}
next(error);
}
}
router.get("/assets/:assetId/display/:version", (request, response, next) =>
sendMedia(request, response, next, "display"),
);
router.get(
"/assets/:assetId/thumbnail/:version",
(request, response, next) =>
sendMedia(request, response, next, "thumbnail"),
);
return router;
}
module.exports = {
createMediaRouter,
};
+235
View File
@@ -0,0 +1,235 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const multer = require("multer");
const { AssetError } = require("./errors");
const ALLOWED_FILE_FIELDS = new Set(["file", "files"]);
const ALLOWED_TEXT_FIELDS = new Set(["display_name", "displayName"]);
const MAX_FIELD_BYTES = 4 * 1024;
const MAX_ORIGINAL_FILENAME_LENGTH = 255;
const TEMPORARY_UPLOAD_PATHS = Symbol("temporaryUploadPaths");
const TEMP_STORAGE_ERROR_CODES = new Set([
"EACCES",
"EEXIST",
"EIO",
"EMFILE",
"ENFILE",
"ENOSPC",
"ENOTDIR",
"EPERM",
"EROFS",
]);
function safeOriginalFilename(value) {
const normalized = String(value || "")
.replaceAll("\\", "/")
.replace(/[\u0000-\u001f\u007f]/g, "");
const leafName = path.posix.basename(normalized);
const bounded = [...leafName].slice(0, MAX_ORIGINAL_FILENAME_LENGTH).join("");
if (!bounded.trim()) {
throw new AssetError(
"INVALID_ORIGINAL_FILENAME",
"Uploaded files must include a usable filename",
);
}
return bounded;
}
function mapMulterError(error, config) {
if (!(error instanceof multer.MulterError)) {
if (TEMP_STORAGE_ERROR_CODES.has(error?.code)) {
return new AssetError(
"UPLOAD_TEMP_STORAGE_UNAVAILABLE",
"Temporary upload storage is unavailable",
{ statusCode: 503, cause: error },
);
}
return new AssetError(
"INVALID_MULTIPART_UPLOAD",
"The multipart upload could not be read",
{ cause: error },
);
}
if (error.code === "LIMIT_FILE_SIZE") {
return new AssetError(
"UPLOAD_FILE_TOO_LARGE",
"An uploaded file exceeds the configured size limit",
{
statusCode: 413,
details: { maxFileBytes: config.maxFileBytes },
},
);
}
if (error.code === "LIMIT_FILE_COUNT") {
return new AssetError(
"UPLOAD_FILE_COUNT_EXCEEDED",
"The upload contains too many files",
{
statusCode: 413,
details: { maxFiles: config.maxFiles },
},
);
}
if (
[
"LIMIT_FIELD_COUNT",
"LIMIT_FIELD_KEY",
"LIMIT_FIELD_VALUE",
"LIMIT_PART_COUNT",
"LIMIT_UNEXPECTED_FILE",
].includes(error.code)
) {
return new AssetError(
"INVALID_MULTIPART_UPLOAD",
"The multipart upload contains too many or invalid parts",
{ details: { reason: error.code } },
);
}
return new AssetError(
"INVALID_MULTIPART_UPLOAD",
"The multipart upload could not be read",
{ details: { reason: error.code } },
);
}
function createTemporaryStorage(tempDirectory) {
return multer.diskStorage({
destination(request, file, callback) {
fs.mkdir(tempDirectory, { recursive: true, mode: 0o700 }, (error) => {
callback(error, tempDirectory);
});
},
filename(request, file, callback) {
const temporaryName = crypto.randomUUID();
if (!request[TEMPORARY_UPLOAD_PATHS]) {
request[TEMPORARY_UPLOAD_PATHS] = [];
}
request[TEMPORARY_UPLOAD_PATHS].push(
path.join(tempDirectory, temporaryName),
);
callback(null, temporaryName);
},
});
}
function temporaryUploadFiles(request) {
const paths = new Set(request[TEMPORARY_UPLOAD_PATHS] || []);
for (const file of Array.isArray(request.files) ? request.files : []) {
if (file?.path) paths.add(file.path);
}
return [...paths].map((temporaryPath) => ({ path: temporaryPath }));
}
function createMultipartUpload(config) {
const middleware = multer({
storage: createTemporaryStorage(config.tempDirectory),
limits: {
fileSize: config.maxFileBytes,
files: config.maxFiles,
fields: 2,
parts: config.maxFiles + 2,
fieldNameSize: 64,
fieldSize: MAX_FIELD_BYTES,
headerPairs: 100,
},
}).any();
return function receiveAssetUpload(request, response) {
return new Promise((resolve, reject) => {
middleware(request, response, (error) => {
if (error) {
reject(mapMulterError(error, config));
return;
}
const files = Array.isArray(request.files) ? request.files : [];
try {
if (files.length === 0) {
throw new AssetError(
"UPLOAD_FILE_REQUIRED",
"At least one image file is required",
);
}
const unexpectedFile = files.find(
(file) => !ALLOWED_FILE_FIELDS.has(file.fieldname),
);
if (unexpectedFile) {
throw new AssetError(
"UNEXPECTED_UPLOAD_FIELD",
"Image files must use the file or files multipart field",
{ details: { field: unexpectedFile.fieldname } },
);
}
const body = request.body || {};
const unknownFields = Object.keys(body).filter(
(name) => !ALLOWED_TEXT_FIELDS.has(name),
);
if (unknownFields.length > 0) {
throw new AssetError(
"UNEXPECTED_UPLOAD_FIELD",
"The multipart upload contains unsupported fields",
{ details: { fields: unknownFields } },
);
}
if (
Object.hasOwn(body, "display_name") &&
Object.hasOwn(body, "displayName")
) {
throw new AssetError(
"DUPLICATE_DISPLAY_NAME_FIELD",
"Provide only one display name field",
);
}
const displayName = body.display_name ?? body.displayName ?? null;
if (displayName !== null && files.length !== 1) {
throw new AssetError(
"DISPLAY_NAME_REQUIRES_SINGLE_FILE",
"An explicit display name can be used only with one uploaded file",
);
}
resolve({
displayName,
files: files.map((file) => ({
path: file.path,
byteSize: file.size,
originalFilename: safeOriginalFilename(file.originalname),
suppliedContentType: file.mimetype,
})),
});
} catch (validationError) {
reject(validationError);
}
});
});
};
}
async function cleanupTemporaryFiles(files = []) {
await Promise.all(
files.map(async (file) => {
if (!file?.path) return;
try {
await fs.promises.rm(file.path, { force: true });
} catch {
// Temporary cleanup is best-effort and must not mask the API result.
}
}),
);
}
module.exports = {
cleanupTemporaryFiles,
createMultipartUpload,
mapMulterError,
safeOriginalFilename,
temporaryUploadFiles,
};
+137
View File
@@ -0,0 +1,137 @@
const express = require("express");
const {
cleanupTemporaryFiles,
createMultipartUpload,
temporaryUploadFiles,
} = require("./multipart");
const { sendOriginal } = require("./streaming");
function createAssetRouter({
assetService,
onBeforePlaybackDomainChange = () => {},
onPlaylistsChanged = () => {},
onPlaybackDomainChanged = () => {},
processingConfig,
}) {
const router = express.Router();
const receiveUpload = createMultipartUpload(processingConfig);
router.get("/", (request, response, next) => {
try {
response.json({ assets: assetService.list() });
} catch (error) {
next(error);
}
});
router.post("/", async (request, response, next) => {
const cleanupAfterAbort = () => {
void cleanupTemporaryFiles(temporaryUploadFiles(request));
};
request.once("aborted", cleanupAfterAbort);
try {
const upload = await receiveUpload(request, response);
const results = await assetService.upload(upload.files, {
displayName: upload.displayName,
});
const createdCount = results.filter((result) => result.created).length;
if (results.length === 1) {
response.status(createdCount > 0 ? 201 : 200).json(results[0]);
return;
}
response.status(createdCount > 0 ? 201 : 200).json({
results,
createdCount,
duplicateCount: results.length - createdCount,
});
} catch (error) {
next(error);
} finally {
request.off("aborted", cleanupAfterAbort);
await cleanupTemporaryFiles(temporaryUploadFiles(request));
}
});
router.get("/:assetId/references", (request, response, next) => {
try {
response.json(assetService.references(request.params.assetId));
} catch (error) {
next(error);
}
});
router.get("/:assetId/original", async (request, response, next) => {
try {
const descriptor = assetService.originalDescriptor(request.params.assetId);
await sendOriginal(assetService, response, descriptor);
} catch (error) {
if (response.headersSent) {
response.destroy(error);
return;
}
next(error);
}
});
router.get("/:assetId", (request, response, next) => {
try {
response.json(assetService.get(request.params.assetId));
} catch (error) {
next(error);
}
});
router.patch("/:assetId", (request, response, next) => {
try {
const previous = assetService.get(request.params.assetId);
const playlistIds = [
...new Set(
assetService
.references(request.params.assetId)
.playlists.map((reference) => reference.playlistId),
),
];
onBeforePlaybackDomainChange({
reason: "before_asset_update",
assetId: previous.id,
});
const updated = assetService.update(request.params.assetId, request.body);
if (updated.updatedAt !== previous.updatedAt) {
const change = {
reason: "asset_updated",
playlistIds,
assetId: updated.id,
...(updated.expirationAt !== previous.expirationAt
? { invalidBoundaryChanged: true }
: {}),
};
onPlaybackDomainChanged(change);
if (playlistIds.length > 0) {
const publicChange = { ...change };
delete publicChange.invalidBoundaryChanged;
onPlaylistsChanged(publicChange);
}
}
response.json(updated);
} catch (error) {
next(error);
}
});
router.delete("/:assetId", async (request, response, next) => {
try {
response.json(await assetService.remove(request.params.assetId));
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createAssetRouter,
};
+851
View File
@@ -0,0 +1,851 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { AssetError, assetNotFound } = require("./errors");
const { StorageObjectNotFoundError } = require("../storage");
const MAX_DISPLAY_NAME_LENGTH = 200;
const MAX_PROCESSING_ERROR_LENGTH = 1_000;
const CONCURRENT_UPLOAD_WAIT_MS = 15_000;
const CONCURRENT_UPLOAD_POLL_MS = 50;
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function normalizeDisplayName(value) {
if (typeof value !== "string") {
throw new AssetError(
"INVALID_ASSET_DISPLAY_NAME",
"Asset display name must be a string",
);
}
const normalized = value
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
throw new AssetError(
"INVALID_ASSET_DISPLAY_NAME",
"Asset display name cannot be empty",
);
}
if ([...normalized].length > MAX_DISPLAY_NAME_LENGTH) {
throw new AssetError(
"INVALID_ASSET_DISPLAY_NAME",
`Asset display name cannot exceed ${MAX_DISPLAY_NAME_LENGTH} characters`,
{ details: { maxLength: MAX_DISPLAY_NAME_LENGTH } },
);
}
return normalized;
}
function defaultDisplayName(originalFilename) {
const extension = path.extname(originalFilename);
const withoutExtension = extension
? originalFilename.slice(0, -extension.length)
: originalFilename;
return normalizeDisplayName(withoutExtension);
}
function normalizeExpiration(value) {
if (value === null) return null;
if (Number.isSafeInteger(value) && value >= 0) return value;
if (typeof value === "string" && value.trim()) {
const timestamp = Date.parse(value);
if (Number.isSafeInteger(timestamp) && timestamp >= 0) {
return timestamp;
}
}
throw new AssetError(
"INVALID_ASSET_EXPIRATION",
"Asset expiration must be null, an epoch-millisecond integer, or an ISO date",
);
}
function mediaUrls(row) {
if (row.status !== "ready") {
return {
originalUrl: null,
displayUrl: null,
thumbnailUrl: null,
};
}
return {
originalUrl: `/api/assets/${row.id}/original`,
displayUrl: `/media/assets/${row.id}/display/${row.display_sha256}.webp`,
thumbnailUrl: `/media/assets/${row.id}/thumbnail/${row.thumbnail_sha256}.webp`,
};
}
function mapAsset(row) {
if (!row) return null;
const urls = mediaUrls(row);
return {
id: row.id,
displayName: row.display_name,
originalFilename: row.original_filename,
status: row.status,
processingError: row.processing_error || null,
sourceSha256: row.source_sha256,
expirationAt: row.expiration_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
referenceCount: Number(row.reference_count || 0),
original: row.original_content_type
? {
contentType: row.original_content_type,
byteSize: row.original_byte_size,
width: row.original_width,
height: row.original_height,
url: urls.originalUrl,
}
: null,
display: row.display_content_type
? {
sha256: row.display_sha256,
contentType: row.display_content_type,
byteSize: row.display_byte_size,
width: row.display_width,
height: row.display_height,
url: urls.displayUrl,
}
: null,
thumbnail: row.thumbnail_content_type
? {
sha256: row.thumbnail_sha256,
contentType: row.thumbnail_content_type,
byteSize: row.thumbnail_byte_size,
width: row.thumbnail_width,
height: row.thumbnail_height,
url: urls.thumbnailUrl,
}
: null,
};
}
async function hashFile(filename) {
const hash = crypto.createHash("sha256");
let byteSize = 0;
for await (const chunk of fs.createReadStream(filename)) {
byteSize += chunk.length;
hash.update(chunk);
}
return { sourceSha256: hash.digest("hex"), byteSize };
}
function objectKeys(assetId, processed) {
return {
original: `assets/${assetId}/original/${processed.original.sha256}`,
display: `assets/${assetId}/display/${processed.display.sha256}.webp`,
thumbnail: `assets/${assetId}/thumbnail/${processed.thumbnail.sha256}.webp`,
};
}
function boundedProcessingError(error) {
const code = typeof error?.code === "string" ? error.code : "PROCESSING_FAILED";
const message =
typeof error?.message === "string" && error.message
? error.message
: "Asset processing failed";
return `${code}: ${message}`.slice(0, MAX_PROCESSING_ERROR_LENGTH);
}
function createAssetService({
database,
storage,
imageProcessor,
now = Date.now,
createId = crypto.randomUUID,
wait = delay,
concurrentUploadWaitMs = CONCURRENT_UPLOAD_WAIT_MS,
onProcessingFailed = () => {},
}) {
const assetSelect = `
SELECT a.*,
(SELECT COUNT(*) FROM playlist_entries pe WHERE pe.asset_id = a.id)
AS reference_count
FROM assets a
`;
const listAssets = database.prepare(`${assetSelect} ORDER BY a.created_at DESC, a.id`);
const findById = database.prepare(`${assetSelect} WHERE a.id = ?`);
const findByHash = database.prepare(`${assetSelect} WHERE a.source_sha256 = ?`);
const findByName = database.prepare(`${assetSelect} WHERE a.display_name = ? COLLATE NOCASE`);
const insertProcessing = database.prepare(`
INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
created_at, updated_at
) VALUES (
@id, @sourceSha256, @displayName, @originalFilename, 'processing',
@createdAt, @updatedAt
)
`);
const markReady = database.prepare(`
UPDATE assets
SET status = 'ready',
processing_error = NULL,
original_object_key = @originalObjectKey,
original_content_type = @originalContentType,
original_byte_size = @originalByteSize,
original_width = @originalWidth,
original_height = @originalHeight,
display_object_key = @displayObjectKey,
display_sha256 = @displaySha256,
display_content_type = @displayContentType,
display_byte_size = @displayByteSize,
display_width = @displayWidth,
display_height = @displayHeight,
thumbnail_object_key = @thumbnailObjectKey,
thumbnail_sha256 = @thumbnailSha256,
thumbnail_content_type = @thumbnailContentType,
thumbnail_byte_size = @thumbnailByteSize,
thumbnail_width = @thumbnailWidth,
thumbnail_height = @thumbnailHeight,
updated_at = @updatedAt
WHERE id = @id AND status = 'processing'
`);
const markFailed = database.prepare(`
UPDATE assets
SET status = 'failed',
processing_error = @processingError,
original_object_key = @originalObjectKey,
original_content_type = @originalContentType,
original_byte_size = @originalByteSize,
original_width = @originalWidth,
original_height = @originalHeight,
display_object_key = @displayObjectKey,
display_sha256 = @displaySha256,
display_content_type = @displayContentType,
display_byte_size = @displayByteSize,
display_width = @displayWidth,
display_height = @displayHeight,
thumbnail_object_key = @thumbnailObjectKey,
thumbnail_sha256 = @thumbnailSha256,
thumbnail_content_type = @thumbnailContentType,
thumbnail_byte_size = @thumbnailByteSize,
thumbnail_width = @thumbnailWidth,
thumbnail_height = @thumbnailHeight,
updated_at = @updatedAt
WHERE id = @id AND status = 'processing'
`);
const updateEditable = database.prepare(`
UPDATE assets
SET display_name = @displayName,
expiration_at = @expirationAt,
updated_at = @updatedAt
WHERE id = @id AND status != 'deleting'
`);
const playlistReferences = database.prepare(`
SELECT
pe.id AS entry_id,
pe.playlist_id,
p.name AS playlist_name,
pe.enabled,
pe.position,
EXISTS (
SELECT 1 FROM playback_state active
WHERE active.active_playlist_id = pe.playlist_id
) AS is_active_playlist,
EXISTS (
SELECT 1 FROM playback_state current
WHERE current.current_entry_id = pe.id
) AS is_current_normal_target
FROM playlist_entries pe
JOIN playlists p ON p.id = pe.playlist_id
WHERE pe.asset_id = ?
ORDER BY p.name COLLATE NOCASE, pe.position, pe.id
`);
const playbackReferences = database.prepare(`
SELECT
output_id,
mode,
active_playlist_id,
current_entry_id,
static_asset_id
FROM playback_state
WHERE static_asset_id = ?
OR current_entry_id IN (
SELECT id FROM playlist_entries WHERE asset_id = ?
)
ORDER BY output_id
`);
const markDeleting = database.prepare(`
UPDATE assets
SET status = 'deleting', updated_at = ?
WHERE id = ? AND status != 'processing'
`);
const deleteAssetRow = database.prepare(
"DELETE FROM assets WHERE id = ? AND status = 'deleting'",
);
const completeAsset = database.transaction((metadata) => {
const result = markReady.run(metadata);
if (result.changes !== 1) {
throw new Error("Asset processing state changed before completion");
}
return findById.get(metadata.id);
});
function getRaw(assetId) {
const row = findById.get(assetId);
if (!row) throw assetNotFound(assetId);
return row;
}
function duplicateStateError(row) {
if (row.status === "failed") {
return new AssetError(
"ASSET_PROCESSING_FAILED",
"These bytes match an asset whose processing previously failed",
{ statusCode: 409, details: { asset: mapAsset(row) } },
);
}
if (row.status === "deleting") {
return new AssetError(
"ASSET_DELETING",
"These bytes match an asset that is being deleted",
{ statusCode: 409, details: { assetId: row.id } },
);
}
return new AssetError(
"ASSET_PROCESSING",
"These bytes are already being processed",
{ statusCode: 409, details: { assetId: row.id } },
);
}
async function resolveExistingHash(row) {
if (row.status === "ready") {
return { created: false, duplicate: true, asset: mapAsset(row) };
}
if (row.status !== "processing") {
throw duplicateStateError(row);
}
const deadline = Date.now() + concurrentUploadWaitMs;
while (Date.now() < deadline) {
await wait(CONCURRENT_UPLOAD_POLL_MS);
const latest = findByHash.get(row.source_sha256);
if (!latest) break;
if (latest.status === "ready") {
return { created: false, duplicate: true, asset: mapAsset(latest) };
}
if (latest.status !== "processing") {
throw duplicateStateError(latest);
}
}
throw duplicateStateError(findByHash.get(row.source_sha256) || row);
}
function nameConflict(displayName, row = findByName.get(displayName)) {
return new AssetError(
"ASSET_NAME_CONFLICT",
"Another library asset already uses that display name",
{
statusCode: 409,
details: row ? { asset: mapAsset(row) } : { displayName },
},
);
}
function processingMetadata(id, processed, keys, error) {
return {
id,
processingError: error ? boundedProcessingError(error) : null,
originalObjectKey: keys.original,
originalContentType: processed.original.contentType,
originalByteSize: processed.original.byteSize,
originalWidth: processed.original.width,
originalHeight: processed.original.height,
displayObjectKey: keys.display,
displaySha256: processed.display.sha256,
displayContentType: processed.display.contentType,
displayByteSize: processed.display.byteSize,
displayWidth: processed.display.width,
displayHeight: processed.display.height,
thumbnailObjectKey: keys.thumbnail,
thumbnailSha256: processed.thumbnail.sha256,
thumbnailContentType: processed.thumbnail.contentType,
thumbnailByteSize: processed.thumbnail.byteSize,
thumbnailWidth: processed.thumbnail.width,
thumbnailHeight: processed.thumbnail.height,
updatedAt: now(),
};
}
async function cleanObjects(keys) {
const results = await Promise.allSettled(
[...new Set(Object.values(keys).filter(Boolean))].map((key) =>
storage.deleteObject(key),
),
);
const rejected = results.find((result) => result.status === "rejected");
if (rejected) throw rejected.reason;
}
async function createProcessedAsset(plan) {
const id = createId();
const timestamp = now();
try {
insertProcessing.run({
id,
sourceSha256: plan.sourceSha256,
displayName: plan.displayName,
originalFilename: plan.file.originalFilename,
createdAt: timestamp,
updatedAt: timestamp,
});
} catch (error) {
const hashMatch = findByHash.get(plan.sourceSha256);
if (hashMatch) return resolveExistingHash(hashMatch);
const nameMatch = findByName.get(plan.displayName);
if (nameMatch) throw nameConflict(plan.displayName, nameMatch);
throw error;
}
const keys = objectKeys(id, plan.processed);
try {
await storage.putObject({
key: keys.original,
body: fs.createReadStream(plan.file.path),
contentType: plan.processed.original.contentType,
contentLength: plan.processed.original.byteSize,
metadata: { sha256: plan.processed.original.sha256 },
});
await storage.putObject({
key: keys.display,
body: plan.processed.display.buffer,
contentType: plan.processed.display.contentType,
contentLength: plan.processed.display.byteSize,
metadata: { sha256: plan.processed.display.sha256 },
});
await storage.putObject({
key: keys.thumbnail,
body: plan.processed.thumbnail.buffer,
contentType: plan.processed.thumbnail.contentType,
contentLength: plan.processed.thumbnail.byteSize,
metadata: { sha256: plan.processed.thumbnail.sha256 },
});
const row = completeAsset(processingMetadata(id, plan.processed, keys));
return { created: true, duplicate: false, asset: mapAsset(row) };
} catch (error) {
const failure = processingMetadata(id, plan.processed, keys, error);
let recorded = false;
try {
markFailed.run(failure);
recorded = true;
} catch {
// Preserve the original failure; the processing row remains diagnosable.
}
if (recorded) {
try {
// One observable transition per newly failed asset. No polling scan
// is needed to notice historical failures.
onProcessingFailed({
assetId: id,
displayName: plan.displayName || plan.file?.originalFilename || null,
message: failure.processingError,
});
} catch {
// A notification hook must never mask the original upload error.
}
}
try {
await cleanObjects(keys);
} catch {
// The failed row retains deterministic keys so DELETE can retry cleanup.
}
if (error?.statusCode) {
error.details = { ...(error.details || {}), assetId: id, retryable: true };
}
throw error;
}
}
async function upload(files, { displayName = null } = {}) {
if (!Array.isArray(files) || files.length === 0) {
throw new AssetError(
"UPLOAD_FILE_REQUIRED",
"At least one image file is required",
);
}
const plans = [];
const firstPlanByHash = new Map();
const plannedNames = new Map();
for (const [index, file] of files.entries()) {
const identity = await hashFile(file.path);
if (identity.byteSize !== file.byteSize) {
throw new AssetError(
"UPLOAD_CHANGED_DURING_PROCESSING",
"An uploaded temporary file changed before processing",
{ statusCode: 409 },
);
}
const existing = findByHash.get(identity.sourceSha256);
if (existing) {
plans.push({ index, existing });
continue;
}
const priorPlan = firstPlanByHash.get(identity.sourceSha256);
if (priorPlan) {
plans.push({ index, duplicateOf: priorPlan });
continue;
}
const proposedName = normalizeDisplayName(
displayName ?? defaultDisplayName(file.originalFilename),
);
const existingName = findByName.get(proposedName);
if (existingName) throw nameConflict(proposedName, existingName);
const normalizedNameKey = proposedName.toLowerCase();
const batchNameMatch = plannedNames.get(normalizedNameKey);
if (batchNameMatch) throw nameConflict(proposedName);
const plan = {
index,
file,
displayName: proposedName,
sourceSha256: identity.sourceSha256,
};
firstPlanByHash.set(identity.sourceSha256, plan);
plannedNames.set(normalizedNameKey, plan);
plans.push(plan);
}
// Decode every new image before any database or object-storage mutation.
for (const plan of plans) {
if (plan.existing || plan.duplicateOf) continue;
plan.processed = await imageProcessor.process(plan.file.path);
if (plan.processed.original.sha256 !== plan.sourceSha256) {
throw new AssetError(
"UPLOAD_CHANGED_DURING_PROCESSING",
"An uploaded temporary file changed before processing",
{ statusCode: 409 },
);
}
}
const resultByPlan = new Map();
const results = new Array(files.length);
for (const plan of plans) {
let result;
if (plan.existing) {
result = await resolveExistingHash(plan.existing);
} else if (plan.duplicateOf) {
const originalResult = resultByPlan.get(plan.duplicateOf);
result = {
created: false,
duplicate: true,
asset: originalResult.asset,
};
} else {
result = await createProcessedAsset(plan);
resultByPlan.set(plan, result);
}
results[plan.index] = result;
}
return results;
}
function list() {
return listAssets.all().map(mapAsset);
}
function get(assetId) {
return mapAsset(getRaw(assetId));
}
function update(assetId, input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new AssetError("INVALID_ASSET_UPDATE", "Asset update must be an object");
}
const allowed = new Set([
"displayName",
"display_name",
"expirationAt",
"expiration_at",
]);
const unknown = Object.keys(input).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
throw new AssetError(
"INVALID_ASSET_UPDATE",
"Asset update contains unsupported fields",
{ details: { fields: unknown } },
);
}
if (Object.keys(input).length === 0) {
throw new AssetError("INVALID_ASSET_UPDATE", "At least one field is required");
}
if (
Object.hasOwn(input, "displayName") &&
Object.hasOwn(input, "display_name")
) {
throw new AssetError("INVALID_ASSET_UPDATE", "Display name was provided twice");
}
if (
Object.hasOwn(input, "expirationAt") &&
Object.hasOwn(input, "expiration_at")
) {
throw new AssetError("INVALID_ASSET_UPDATE", "Expiration was provided twice");
}
const current = getRaw(assetId);
if (current.status === "deleting") {
throw new AssetError("ASSET_DELETING", "Deleting assets cannot be edited", {
statusCode: 409,
details: { assetId },
});
}
const hasDisplayName =
Object.hasOwn(input, "displayName") || Object.hasOwn(input, "display_name");
const hasExpiration =
Object.hasOwn(input, "expirationAt") || Object.hasOwn(input, "expiration_at");
const nextDisplayName = hasDisplayName
? normalizeDisplayName(
Object.hasOwn(input, "displayName")
? input.displayName
: input.display_name,
)
: current.display_name;
const nextExpiration = hasExpiration
? normalizeExpiration(
Object.hasOwn(input, "expirationAt")
? input.expirationAt
: input.expiration_at,
)
: current.expiration_at;
if (
nextDisplayName === current.display_name &&
nextExpiration === current.expiration_at
) {
return mapAsset(current);
}
try {
const result = updateEditable.run({
id: assetId,
displayName: nextDisplayName,
expirationAt: nextExpiration,
updatedAt: Math.max(now(), current.updated_at + 1),
});
if (result.changes !== 1) {
throw new AssetError("ASSET_DELETING", "Deleting assets cannot be edited", {
statusCode: 409,
details: { assetId },
});
}
} catch (error) {
const nameMatch = findByName.get(nextDisplayName);
if (nameMatch && nameMatch.id !== assetId) {
throw nameConflict(nextDisplayName, nameMatch);
}
throw error;
}
return mapAsset(getRaw(assetId));
}
function references(assetId) {
const asset = getRaw(assetId);
const playlists = playlistReferences.all(assetId).map((row) => ({
playlistId: row.playlist_id,
playlistName: row.playlist_name,
playlistEntryId: row.entry_id,
enabled: Boolean(row.enabled),
position: row.position,
isActivePlaylist: Boolean(row.is_active_playlist),
isCurrentNormalTarget: Boolean(row.is_current_normal_target),
}));
const playback = playbackReferences.all(assetId, assetId).map((row) => ({
outputId: row.output_id,
mode: row.mode,
activePlaylistId: row.active_playlist_id,
currentPlaylistEntryId: row.current_entry_id,
isCurrentNormalTarget: playlists.some(
(entry) => entry.playlistEntryId === row.current_entry_id,
),
isCurrentStaticTarget: row.static_asset_id === assetId,
}));
const currentNormalTarget = playback.some((state) => state.isCurrentNormalTarget);
const currentStaticTarget = playback.some((state) => state.isCurrentStaticTarget);
const blockerCodes = [];
if (playlists.length > 0) blockerCodes.push("PLAYLIST_REFERENCES");
if (currentNormalTarget) blockerCodes.push("CURRENT_PLAYBACK_TARGET");
if (currentStaticTarget) blockerCodes.push("CURRENT_STATIC_TARGET");
if (asset.status === "processing") blockerCodes.push("ASSET_PROCESSING");
if (asset.status === "deleting") blockerCodes.push("ASSET_DELETING");
return {
assetId,
playlists,
playback,
currentNormalTarget,
currentStaticTarget,
canDelete: blockerCodes.length === 0,
blockerCodes,
};
}
const beginDelete = database.transaction((assetId) => {
const asset = getRaw(assetId);
const referenceInfo = references(assetId);
const relationshipBlockers = referenceInfo.blockerCodes.filter((code) =>
[
"PLAYLIST_REFERENCES",
"CURRENT_PLAYBACK_TARGET",
"CURRENT_STATIC_TARGET",
].includes(code),
);
if (relationshipBlockers.length > 0) {
throw new AssetError(
referenceInfo.playlists.length > 0 ? "ASSET_REFERENCED" : "ASSET_IN_USE",
"The library asset is still referenced and cannot be deleted",
{
statusCode: 409,
details: { ...referenceInfo, blockerCodes: relationshipBlockers },
},
);
}
if (asset.status === "processing") {
throw new AssetError(
"ASSET_PROCESSING",
"An asset cannot be deleted while it is processing",
{ statusCode: 409, details: { assetId } },
);
}
if (asset.status !== "deleting") {
markDeleting.run(Math.max(now(), asset.updated_at + 1), assetId);
}
return findById.get(assetId);
});
async function remove(assetId) {
const deleting = beginDelete(assetId);
const keys = {
original: deleting.original_object_key,
display: deleting.display_object_key,
thumbnail: deleting.thumbnail_object_key,
};
try {
await cleanObjects(keys);
} catch (error) {
if (error?.statusCode) {
error.details = { ...(error.details || {}), assetId, retryable: true };
}
throw error;
}
try {
const result = deleteAssetRow.run(assetId);
if (result.changes !== 1) throw assetNotFound(assetId);
} catch (error) {
if (error?.code?.startsWith?.("SQLITE_CONSTRAINT")) {
throw new AssetError(
"ASSET_REFERENCED",
"The library asset became referenced before deletion completed",
{ statusCode: 409, details: { assetId, retryable: true } },
);
}
throw error;
}
return { id: assetId, deleted: true };
}
function mediaDescriptor(assetId, kind, requestedHash) {
if (!SHA256_PATTERN.test(requestedHash)) {
throw new AssetError("MEDIA_NOT_FOUND", "Asset media version not found", {
statusCode: 404,
});
}
const asset = getRaw(assetId);
const fields =
kind === "display"
? {
hash: asset.display_sha256,
key: asset.display_object_key,
contentType: asset.display_content_type,
contentLength: asset.display_byte_size,
}
: {
hash: asset.thumbnail_sha256,
key: asset.thumbnail_object_key,
contentType: asset.thumbnail_content_type,
contentLength: asset.thumbnail_byte_size,
};
if (asset.status !== "ready" || fields.hash !== requestedHash || !fields.key) {
throw new AssetError("MEDIA_NOT_FOUND", "Asset media version not found", {
statusCode: 404,
});
}
return { asset, kind, ...fields };
}
function originalDescriptor(assetId) {
const asset = getRaw(assetId);
if (asset.status !== "ready" || !asset.original_object_key) {
throw new AssetError("ASSET_ORIGINAL_NOT_READY", "Asset original is not ready", {
statusCode: 409,
details: { assetId, status: asset.status },
});
}
return {
asset,
key: asset.original_object_key,
hash: asset.source_sha256,
contentType: asset.original_content_type,
contentLength: asset.original_byte_size,
};
}
async function readStoredObject(descriptor) {
try {
return await storage.getObject(descriptor.key);
} catch (error) {
if (error instanceof StorageObjectNotFoundError) {
throw new AssetError(
"STORAGE_INCONSISTENCY",
"Asset metadata exists but its stored bytes are unavailable",
{
statusCode: 503,
details: { assetId: descriptor.asset.id, retryable: true },
cause: error,
},
);
}
throw error;
}
}
return Object.freeze({
list,
get,
upload,
update,
references,
remove,
mediaDescriptor,
originalDescriptor,
readStoredObject,
});
}
module.exports = {
createAssetService,
defaultDisplayName,
mapAsset,
normalizeDisplayName,
normalizeExpiration,
};
+66
View File
@@ -0,0 +1,66 @@
const { Readable } = require("node:stream");
const { pipeline } = require("node:stream/promises");
const { AssetError } = require("./errors");
function readableBody(body) {
if (body && typeof body.pipe === "function") return body;
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
return Readable.from([body]);
}
if (body && typeof body.getReader === "function") {
return Readable.fromWeb(body);
}
throw new AssetError(
"STORAGE_INCONSISTENCY",
"Stored asset bytes could not be streamed",
{ statusCode: 503 },
);
}
function validateDescriptor(descriptor) {
if (
typeof descriptor.contentType !== "string" ||
!Number.isSafeInteger(descriptor.contentLength) ||
descriptor.contentLength < 0
) {
throw new AssetError(
"STORAGE_INCONSISTENCY",
"Stored asset metadata is incomplete",
{ statusCode: 503 },
);
}
}
function setCommonHeaders(response, descriptor) {
response.set("Content-Type", descriptor.contentType);
response.set("Content-Length", String(descriptor.contentLength));
response.set("X-Content-Type-Options", "nosniff");
}
async function storedBody(assetService, descriptor) {
const stored = await assetService.readStoredObject(descriptor);
return readableBody(stored.body);
}
async function sendImmutableMedia(assetService, response, descriptor) {
validateDescriptor(descriptor);
const body = await storedBody(assetService, descriptor);
setCommonHeaders(response, descriptor);
response.set("Cache-Control", "public, max-age=31536000, immutable");
response.set("ETag", `"${descriptor.hash}"`);
await pipeline(body, response);
}
async function sendOriginal(assetService, response, descriptor) {
validateDescriptor(descriptor);
const body = await storedBody(assetService, descriptor);
response.attachment(descriptor.asset.original_filename);
setCommonHeaders(response, descriptor);
response.set("Cache-Control", "private, no-store");
await pipeline(body, response);
}
module.exports = {
sendImmutableMedia,
sendOriginal,
};
+300
View File
@@ -0,0 +1,300 @@
const path = require("node:path");
class ConfigError extends Error {
constructor(message, field) {
super(message);
this.name = "ConfigError";
this.field = field;
}
}
function requiredString(env, name) {
const value = env[name]?.trim();
if (!value) {
throw new ConfigError(`${name} is required`, name);
}
return value;
}
function optionalString(env, name) {
const value = env[name]?.trim();
return value || null;
}
function integer(env, name, defaultValue, { min, max } = {}) {
const raw = env[name];
if (raw === undefined || raw === "") {
return defaultValue;
}
if (!/^-?\d+$/.test(raw)) {
throw new ConfigError(`${name} must be an integer`, name);
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new ConfigError(`${name} must be a safe integer`, name);
}
if (min !== undefined && value < min) {
throw new ConfigError(`${name} must be at least ${min}`, name);
}
if (max !== undefined && value > max) {
throw new ConfigError(`${name} must be at most ${max}`, name);
}
return value;
}
function boolean(env, name, defaultValue) {
const raw = env[name];
if (raw === undefined || raw === "") {
return defaultValue;
}
const normalized = raw.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) {
return true;
}
if (["false", "0", "no", "off"].includes(normalized)) {
return false;
}
throw new ConfigError(`${name} must be a boolean`, name);
}
function url(env, name, { required = false, protocols } = {}) {
const raw = required ? requiredString(env, name) : optionalString(env, name);
if (raw === null) {
return null;
}
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new ConfigError(`${name} must be a valid URL`, name);
}
if (protocols && !protocols.includes(parsed.protocol)) {
throw new ConfigError(
`${name} must use one of these protocols: ${protocols.join(", ")}`,
name,
);
}
return parsed.toString().replace(/\/$/, "");
}
function topicPrefix(env) {
const value = env.MQTT_TOPIC_PREFIX?.trim() || "announcements";
if (/[+#]/.test(value)) {
throw new ConfigError(
"MQTT_TOPIC_PREFIX cannot contain the + or # wildcards",
"MQTT_TOPIC_PREFIX",
);
}
if (value.startsWith("/") || value.endsWith("/")) {
throw new ConfigError(
"MQTT_TOPIC_PREFIX cannot start or end with /",
"MQTT_TOPIC_PREFIX",
);
}
return value;
}
function bucketName(env) {
const value = requiredString(env, "S3_BUCKET");
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)) {
throw new ConfigError(
"S3_BUCKET must be a valid lowercase S3 bucket name",
"S3_BUCKET",
);
}
return value;
}
function nodeEnvironment(env) {
const value = env.NODE_ENV?.trim() || "development";
if (!["development", "test", "production"].includes(value)) {
throw new ConfigError(
"NODE_ENV must be development, test, or production",
"NODE_ENV",
);
}
return value;
}
function timeZone(env) {
const value = requiredString(env, "APP_TIMEZONE");
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
} catch {
throw new ConfigError("APP_TIMEZONE must be a valid IANA time zone", "APP_TIMEZONE");
}
return value;
}
function storageCredential(env, name, environment) {
const value = optionalString(env, name);
if (environment === "production" && value === null) {
throw new ConfigError(`${name} is required in production`, name);
}
return value;
}
function loadConfig(env = process.env) {
const environment = nodeEnvironment(env);
const databasePath = requiredString(env, "DATABASE_PATH");
if (databasePath !== ":memory:" && !path.isAbsolute(databasePath)) {
throw new ConfigError("DATABASE_PATH must be an absolute path", "DATABASE_PATH");
}
return Object.freeze({
environment,
host: env.HOST?.trim() || "0.0.0.0",
port: integer(env, "PORT", 7532, { min: 0, max: 65535 }),
timeZone: timeZone(env),
shutdownTimeoutMs: integer(env, "SHUTDOWN_TIMEOUT_MS", 10_000, {
min: 1_000,
max: 60_000,
}),
trustProxy: boolean(env, "TRUST_PROXY", true),
database: Object.freeze({
path: databasePath,
busyTimeoutMs: integer(env, "DATABASE_BUSY_TIMEOUT_MS", 5_000, {
min: 0,
max: 60_000,
}),
}),
storage: Object.freeze({
endpoint: url(env, "S3_ENDPOINT", {
required: true,
protocols: ["http:", "https:"],
}),
region: env.S3_REGION?.trim() || "us-east-1",
bucket: bucketName(env),
accessKeyId: storageCredential(env, "S3_ACCESS_KEY_ID", environment),
secretAccessKey: storageCredential(env, "S3_SECRET_ACCESS_KEY", environment),
forcePathStyle: boolean(env, "S3_FORCE_PATH_STYLE", true),
healthProbeIntervalMs: integer(
env,
"S3_HEALTH_PROBE_INTERVAL_MS",
30_000,
{ min: 1_000, max: 300_000 },
),
connectionTimeoutMs: integer(env, "S3_CONNECT_TIMEOUT_MS", 2_000, {
min: 250,
max: 60_000,
}),
requestTimeoutMs: integer(env, "S3_REQUEST_TIMEOUT_MS", 5_000, {
min: 1_000,
max: 120_000,
}),
maxAttempts: integer(env, "S3_MAX_ATTEMPTS", 2, { min: 1, max: 5 }),
}),
processing: Object.freeze({
tempDirectory: (() => {
const value = env.UPLOAD_TEMP_DIRECTORY?.trim() || "/tmp/announcements-uploads";
if (!path.isAbsolute(value)) {
throw new ConfigError(
"UPLOAD_TEMP_DIRECTORY must be an absolute path",
"UPLOAD_TEMP_DIRECTORY",
);
}
return value;
})(),
maxFileBytes: integer(env, "UPLOAD_MAX_FILE_BYTES", 25 * 1024 * 1024, {
min: 1,
max: 1024 * 1024 * 1024,
}),
maxFiles: integer(env, "UPLOAD_MAX_FILES", 20, { min: 1, max: 100 }),
maxInputPixels: integer(env, "IMAGE_MAX_INPUT_PIXELS", 40_000_000, {
min: 1,
max: 500_000_000,
}),
}),
mqtt: Object.freeze({
url: url(env, "MQTT_URL", {
protocols: ["mqtt:", "mqtts:", "ws:", "wss:"],
}),
username: optionalString(env, "MQTT_USERNAME"),
password: optionalString(env, "MQTT_PASSWORD"),
topicPrefix: topicPrefix(env),
reconnectPeriodMs: integer(env, "MQTT_RECONNECT_PERIOD_MS", 5_000, {
min: 250,
max: 300_000,
}),
connectTimeoutMs: integer(env, "MQTT_CONNECT_TIMEOUT_MS", 10_000, {
min: 250,
max: 120_000,
}),
}),
notifications: Object.freeze({
homeAssistantWebhookUrl: url(env, "HA_WEBHOOK_URL", {
protocols: ["http:", "https:"],
}),
homeAssistantWebhookSecret: optionalString(env, "HA_WEBHOOK_SECRET"),
webhookTimeoutMs: integer(env, "HA_WEBHOOK_TIMEOUT_MS", 10_000, {
min: 250,
max: 120_000,
}),
}),
displays: Object.freeze({
offlineThresholdMs: integer(env, "DISPLAY_OFFLINE_THRESHOLD_MS", 45_000, {
min: 5_000,
max: 3_600_000,
}),
}),
});
}
function redactedConfig(config) {
return {
environment: config.environment,
host: config.host,
port: config.port,
timeZone: config.timeZone,
trustProxy: config.trustProxy,
database: {
configured: Boolean(config.database.path),
},
storage: {
configured: Boolean(
config.storage.endpoint &&
config.storage.bucket &&
config.storage.accessKeyId &&
config.storage.secretAccessKey,
),
region: config.storage.region,
bucket: config.storage.bucket,
forcePathStyle: config.storage.forcePathStyle,
healthProbeIntervalMs: config.storage.healthProbeIntervalMs,
connectionTimeoutMs: config.storage.connectionTimeoutMs,
requestTimeoutMs: config.storage.requestTimeoutMs,
maxAttempts: config.storage.maxAttempts,
},
processing: {
tempDirectoryConfigured: Boolean(config.processing.tempDirectory),
maxFileBytes: config.processing.maxFileBytes,
maxFiles: config.processing.maxFiles,
maxInputPixels: config.processing.maxInputPixels,
},
// Status output must survive a partial or unexpected configuration shape:
// this mapping feeds the endpoints an operator uses when things are wrong.
mqtt: {
configured: Boolean(config.mqtt?.url),
topicPrefix: config.mqtt?.topicPrefix ?? null,
reconnectPeriodMs: config.mqtt?.reconnectPeriodMs ?? null,
},
notifications: {
configured: Boolean(config.notifications?.homeAssistantWebhookUrl),
webhookTimeoutMs: config.notifications?.webhookTimeoutMs ?? null,
},
displays: {
offlineThresholdMs: config.displays?.offlineThresholdMs ?? null,
},
};
}
module.exports = {
ConfigError,
loadConfig,
redactedConfig,
};
+32
View File
@@ -0,0 +1,32 @@
const fs = require("node:fs");
const path = require("node:path");
const Database = require("better-sqlite3");
function openDatabase({ path: filename, busyTimeoutMs = 5_000 }) {
if (filename !== ":memory:") {
fs.mkdirSync(path.dirname(filename), { recursive: true });
}
const database = new Database(filename, {
timeout: busyTimeoutMs,
});
database.pragma("foreign_keys = ON");
database.pragma(`busy_timeout = ${busyTimeoutMs}`);
if (filename !== ":memory:") {
database.pragma("journal_mode = WAL");
database.pragma("synchronous = NORMAL");
}
return database;
}
function checkDatabase(database) {
database.prepare("SELECT 1 AS ok").get();
return true;
}
module.exports = {
checkDatabase,
openDatabase,
};
+122
View File
@@ -0,0 +1,122 @@
const fs = require("node:fs");
const path = require("node:path");
const DEFAULT_MIGRATIONS_DIRECTORY = path.join(__dirname, "migrations");
const MIGRATION_FILENAME = /^(\d+)_([a-z0-9_-]+)\.sql$/i;
function discoverMigrations(directory = DEFAULT_MIGRATIONS_DIRECTORY) {
const migrations = fs
.readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
.map((entry) => {
const match = MIGRATION_FILENAME.exec(entry.name);
if (!match) {
throw new Error(`Invalid migration filename: ${entry.name}`);
}
return {
version: Number(match[1]),
name: match[2],
filename: entry.name,
sql: fs.readFileSync(path.join(directory, entry.name), "utf8"),
};
})
.sort((left, right) => left.version - right.version);
const versions = new Set();
for (const migration of migrations) {
if (!Number.isSafeInteger(migration.version) || migration.version < 1) {
throw new Error(`Invalid migration version: ${migration.filename}`);
}
if (versions.has(migration.version)) {
throw new Error(`Duplicate migration version: ${migration.version}`);
}
versions.add(migration.version);
}
return migrations;
}
function ensureMigrationsTable(database) {
database.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at INTEGER NOT NULL
)
`);
}
function readAppliedMigrations(database) {
return database
.prepare(
`SELECT version, name, applied_at AS appliedAt
FROM schema_migrations
ORDER BY version`,
)
.all();
}
function migrationStatus(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
ensureMigrationsTable(database);
const available = discoverMigrations(directory);
const applied = readAppliedMigrations(database);
const appliedVersions = new Set(applied.map((migration) => migration.version));
return {
currentVersion: applied.at(-1)?.version ?? 0,
latestAvailableVersion: available.at(-1)?.version ?? 0,
pendingVersions: available
.filter((migration) => !appliedVersions.has(migration.version))
.map((migration) => migration.version),
applied,
};
}
function runMigrations(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
ensureMigrationsTable(database);
const migrations = discoverMigrations(directory);
const applied = new Map(
readAppliedMigrations(database).map((migration) => [
migration.version,
migration.name,
]),
);
const availableVersions = new Set(migrations.map((migration) => migration.version));
for (const [version] of applied) {
if (!availableVersions.has(version)) {
throw new Error(`Database contains unknown migration version ${version}`);
}
}
const insertMigration = database.prepare(
`INSERT INTO schema_migrations (version, name, applied_at)
VALUES (?, ?, ?)`,
);
const applyMigration = database.transaction((migration) => {
database.exec(migration.sql);
insertMigration.run(migration.version, migration.name, Date.now());
});
for (const migration of migrations) {
const appliedName = applied.get(migration.version);
if (appliedName !== undefined) {
if (appliedName !== migration.name) {
throw new Error(
`Migration ${migration.version} was applied as ${appliedName}, not ${migration.name}`,
);
}
continue;
}
applyMigration(migration);
}
return migrationStatus(database, directory);
}
module.exports = {
DEFAULT_MIGRATIONS_DIRECTORY,
discoverMigrations,
migrationStatus,
runMigrations,
};
@@ -0,0 +1,214 @@
CREATE TABLE assets (
id TEXT PRIMARY KEY,
source_sha256 TEXT NOT NULL UNIQUE
CHECK (length(source_sha256) = 64),
display_name TEXT NOT NULL COLLATE NOCASE UNIQUE
CHECK (length(trim(display_name)) > 0),
original_filename TEXT NOT NULL
CHECK (length(trim(original_filename)) > 0),
status TEXT NOT NULL
CHECK (status IN ('processing', 'ready', 'failed', 'deleting')),
processing_error TEXT,
expiration_at INTEGER,
original_object_key TEXT,
original_content_type TEXT,
original_byte_size INTEGER CHECK (original_byte_size IS NULL OR original_byte_size >= 0),
original_width INTEGER CHECK (original_width IS NULL OR original_width > 0),
original_height INTEGER CHECK (original_height IS NULL OR original_height > 0),
display_object_key TEXT,
display_sha256 TEXT CHECK (display_sha256 IS NULL OR length(display_sha256) = 64),
display_content_type TEXT,
display_byte_size INTEGER CHECK (display_byte_size IS NULL OR display_byte_size >= 0),
display_width INTEGER CHECK (display_width IS NULL OR display_width > 0),
display_height INTEGER CHECK (display_height IS NULL OR display_height > 0),
thumbnail_object_key TEXT,
thumbnail_sha256 TEXT CHECK (thumbnail_sha256 IS NULL OR length(thumbnail_sha256) = 64),
thumbnail_content_type TEXT,
thumbnail_byte_size INTEGER CHECK (thumbnail_byte_size IS NULL OR thumbnail_byte_size >= 0),
thumbnail_width INTEGER CHECK (thumbnail_width IS NULL OR thumbnail_width > 0),
thumbnail_height INTEGER CHECK (thumbnail_height IS NULL OR thumbnail_height > 0),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX assets_lifecycle_idx ON assets (status, expiration_at);
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL COLLATE NOCASE UNIQUE
CHECK (length(trim(name)) > 0),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE playlist_entries (
id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
duration_override_ms INTEGER
CHECK (duration_override_ms IS NULL OR duration_override_ms > 0),
background_mode_override TEXT
CHECK (
background_mode_override IS NULL OR
background_mode_override IN ('blurred', 'solid', 'black')
),
background_color_override TEXT,
background_darkened_override INTEGER
CHECK (
background_darkened_override IS NULL OR
background_darkened_override IN (0, 1)
),
schedule_start_at INTEGER,
schedule_end_at INTEGER,
position INTEGER NOT NULL CHECK (position >= 0),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
UNIQUE (playlist_id, position),
UNIQUE (playlist_id, asset_id),
CHECK (
schedule_start_at IS NULL OR
schedule_end_at IS NULL OR
schedule_start_at < schedule_end_at
)
);
CREATE INDEX playlist_entries_asset_idx ON playlist_entries (asset_id);
CREATE INDEX playlist_entries_schedule_idx
ON playlist_entries (playlist_id, enabled, schedule_start_at, schedule_end_at);
CREATE TABLE app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
default_duration_ms INTEGER NOT NULL DEFAULT 7000
CHECK (default_duration_ms > 0),
default_background_mode TEXT NOT NULL DEFAULT 'blurred'
CHECK (default_background_mode IN ('blurred', 'solid', 'black')),
default_solid_background_color TEXT NOT NULL DEFAULT '#000000'
CHECK (length(trim(default_solid_background_color)) > 0),
default_background_darkened INTEGER NOT NULL DEFAULT 0
CHECK (default_background_darkened IN (0, 1)),
default_blur_px INTEGER NOT NULL DEFAULT 20
CHECK (default_blur_px >= 0),
crossfade_duration_ms INTEGER NOT NULL DEFAULT 1000
CHECK (crossfade_duration_ms >= 0),
updated_at INTEGER NOT NULL
);
INSERT INTO app_settings (id, updated_at)
VALUES (1, CAST(strftime('%s', 'now') AS INTEGER) * 1000);
CREATE TABLE playback_state (
output_id TEXT PRIMARY KEY,
mode TEXT NOT NULL DEFAULT 'on' CHECK (mode IN ('on', 'off')),
active_playlist_id TEXT,
pending_playlist_id TEXT,
pending_playlist_requested_at INTEGER,
current_entry_id TEXT,
rotation_paused INTEGER NOT NULL DEFAULT 0 CHECK (rotation_paused IN (0, 1)),
paused_remaining_ms INTEGER
CHECK (paused_remaining_ms IS NULL OR paused_remaining_ms >= 0),
static_asset_id TEXT,
slide_started_at INTEGER,
next_slide_at INTEGER,
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
updated_at INTEGER NOT NULL,
FOREIGN KEY (active_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL,
FOREIGN KEY (pending_playlist_id) REFERENCES playlists(id) ON DELETE RESTRICT,
FOREIGN KEY (current_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
FOREIGN KEY (static_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
CHECK (
(pending_playlist_id IS NULL AND pending_playlist_requested_at IS NULL) OR
(pending_playlist_id IS NOT NULL AND pending_playlist_requested_at IS NOT NULL)
)
);
INSERT INTO playback_state (output_id, updated_at)
VALUES ('global', CAST(strftime('%s', 'now') AS INTEGER) * 1000);
CREATE TABLE asset_import_sources (
id TEXT PRIMARY KEY,
source_system TEXT NOT NULL,
source_bucket TEXT NOT NULL,
source_key TEXT NOT NULL,
source_etag TEXT,
source_byte_size INTEGER CHECK (source_byte_size IS NULL OR source_byte_size >= 0),
source_sha256 TEXT CHECK (source_sha256 IS NULL OR length(source_sha256) = 64),
asset_id TEXT,
initial_playlist_entry_id TEXT,
status TEXT NOT NULL
CHECK (status IN ('pending', 'processing', 'imported', 'failed')),
error TEXT,
imported_at INTEGER,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE SET NULL,
FOREIGN KEY (initial_playlist_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
UNIQUE (source_system, source_bucket, source_key)
);
CREATE INDEX asset_import_sources_status_idx ON asset_import_sources (status);
CREATE TABLE notification_incidents (
id TEXT PRIMARY KEY,
condition_key TEXT NOT NULL,
episode INTEGER NOT NULL CHECK (episode > 0),
event TEXT NOT NULL,
severity TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('open', 'recovered')),
message TEXT NOT NULL,
context_json TEXT NOT NULL DEFAULT '{}',
opened_at INTEGER NOT NULL,
last_observed_at INTEGER NOT NULL,
recovered_at INTEGER,
UNIQUE (condition_key, episode),
CHECK (
(state = 'open' AND recovered_at IS NULL) OR
(state = 'recovered' AND recovered_at IS NOT NULL)
)
);
CREATE UNIQUE INDEX notification_incidents_one_open_idx
ON notification_incidents (condition_key)
WHERE state = 'open';
CREATE TABLE notification_outbox (
id TEXT PRIMARY KEY,
incident_id TEXT NOT NULL,
transition TEXT NOT NULL CHECK (transition IN ('failure', 'recovery')),
payload_json TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
next_attempt_at INTEGER NOT NULL,
delivered_at INTEGER,
last_error TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (incident_id) REFERENCES notification_incidents(id) ON DELETE CASCADE,
UNIQUE (incident_id, transition)
);
CREATE INDEX notification_outbox_due_idx
ON notification_outbox (next_attempt_at)
WHERE delivered_at IS NULL;
CREATE TABLE display_clients (
client_id TEXT PRIMARY KEY,
output_id TEXT NOT NULL DEFAULT 'global',
first_seen_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
active_playlist_id TEXT,
playlist_revision INTEGER,
playback_revision INTEGER,
current_entry_id TEXT,
current_asset_id TEXT,
cached_asset_count INTEGER CHECK (cached_asset_count IS NULL OR cached_asset_count >= 0),
playable_asset_count INTEGER CHECK (playable_asset_count IS NULL OR playable_asset_count >= 0),
last_error TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX display_clients_last_seen_idx ON display_clients (last_seen_at);
+19
View File
@@ -0,0 +1,19 @@
const express = require("express");
function createDisplayRouter(displayService) {
const router = express.Router();
router.get("/", (request, response, next) => {
try {
response.json({ displays: displayService.list() });
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createDisplayRouter,
};
+415
View File
@@ -0,0 +1,415 @@
const HEARTBEAT_PROTOCOL_VERSION = 1;
const MAX_ID_LENGTH = 200;
const MAX_ERROR_LENGTH = 500;
const MAX_COUNT = 1_000_000;
const DEFAULT_OFFLINE_THRESHOLD_MS = 45_000;
function optionalId(value, field) {
if (value === undefined || value === null) return null;
if (typeof value !== "string") throw new TypeError(`${field} must be a string`);
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.length > MAX_ID_LENGTH) {
throw new TypeError(`${field} is too long`);
}
return trimmed;
}
function requiredId(value, field) {
const id = optionalId(value, field);
if (!id) throw new TypeError(`${field} is required`);
return id;
}
function optionalCount(value, field) {
if (value === undefined || value === null) return null;
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_COUNT) {
throw new TypeError(`${field} must be an integer from 0 through ${MAX_COUNT}`);
}
return value;
}
function optionalRevision(value, field) {
if (value === undefined || value === null) return null;
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError(`${field} must be a non-negative integer`);
}
return value;
}
// Telemetry is attacker-adjacent input from a kiosk browser. It is bounded
// here so a looping client cannot grow the database without limit.
function boundedError(value) {
if (value === undefined || value === null) return null;
if (typeof value !== "string") throw new TypeError("mostRecentError must be a string");
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > MAX_ERROR_LENGTH
? `${trimmed.slice(0, MAX_ERROR_LENGTH - 1)}…`
: trimmed;
}
// The display serializes its structured error to bounded JSON, which the
// string validation above already accepts. Reading it back as an object keeps
// the status API useful without loosening input validation or needing a
// migration, since display_clients.last_error is TEXT.
function parseDisplayError(value) {
if (typeof value !== "string" || !value.startsWith("{")) return value ?? null;
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && typeof parsed.kind === "string") {
return parsed;
}
} catch {
// A plain string that merely looks like JSON stays a plain string.
}
return value;
}
// Only validated error codes become notifications. Arbitrary browser text
// stays telemetry and never reaches Home Assistant.
const DISPLAY_ERROR_SEVERITY = Object.freeze({
slide_decode_failed: "warning",
asset_fetch_failed: "warning",
// A display that cannot write its cache still shows slides now, but has lost
// its ability to survive the next outage.
cache_write_failed: "warning",
// Nothing renderable for an active playlist is an on-air problem.
manifest_invalid: "critical",
offline_capability_unavailable: "warning",
});
const DISPLAY_ERROR_MESSAGES = Object.freeze({
slide_decode_failed: "could not decode a slide",
asset_fetch_failed: "could not fetch a slide",
cache_write_failed: "could not store slides for offline use",
manifest_invalid: "cannot render any slide of its active playlist",
offline_capability_unavailable: "cannot cache slides for offline use",
});
function errorConditionKey(clientId, error) {
const scope = error.assetId ? `:${error.assetId}` : "";
return `display:${clientId}:${error.kind}${scope}`;
}
function validateHeartbeat(payload) {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
throw new TypeError("heartbeat must be an object");
}
if (payload.protocolVersion !== HEARTBEAT_PROTOCOL_VERSION) {
throw new TypeError(
`protocolVersion must be ${HEARTBEAT_PROTOCOL_VERSION}`,
);
}
return {
clientId: requiredId(payload.clientId, "clientId"),
outputId: optionalId(payload.outputId, "outputId") || "global",
activePlaylistId: optionalId(payload.activePlaylistId, "activePlaylistId"),
playlistRevision: optionalRevision(payload.playlistRevision, "playlistRevision"),
playbackRevision: optionalRevision(payload.playbackRevision, "playbackRevision"),
currentEntryId: optionalId(payload.currentEntryId, "currentEntryId"),
currentAssetId: optionalId(payload.currentAssetId, "currentAssetId"),
cachedAssetCount: optionalCount(payload.cachedAssetCount, "cachedAssetCount"),
playableAssetCount: optionalCount(payload.playableAssetCount, "playableAssetCount"),
lastError: boundedError(payload.mostRecentError),
};
}
function createDisplayService({
database,
notifications = null,
now = Date.now,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
offlineThresholdMs = DEFAULT_OFFLINE_THRESHOLD_MS,
onStatusChanged = () => {},
logger = { warn() {} },
} = {}) {
if (!database) throw new TypeError("database is required");
const findClient = database.prepare(
"SELECT * FROM display_clients WHERE client_id = ?",
);
const listClients = database.prepare(
"SELECT * FROM display_clients ORDER BY first_seen_at, client_id",
);
const insertClient = database.prepare(`
INSERT INTO display_clients (
client_id, output_id, first_seen_at, last_seen_at, active_playlist_id,
playlist_revision, playback_revision, current_entry_id, current_asset_id,
cached_asset_count, playable_asset_count, last_error, updated_at
) VALUES (
@clientId, @outputId, @at, @at, @activePlaylistId, @playlistRevision,
@playbackRevision, @currentEntryId, @currentAssetId, @cachedAssetCount,
@playableAssetCount, @lastError, @at
)
`);
const updateClient = database.prepare(`
UPDATE display_clients
SET output_id = @outputId,
last_seen_at = @at,
active_playlist_id = @activePlaylistId,
playlist_revision = @playlistRevision,
playback_revision = @playbackRevision,
current_entry_id = @currentEntryId,
current_asset_id = @currentAssetId,
cached_asset_count = @cachedAssetCount,
playable_asset_count = @playableAssetCount,
last_error = @lastError,
updated_at = @at
WHERE client_id = @clientId
`);
let running = false;
let timerHandle = null;
// Restarting the API is not evidence that a display disappeared. Nothing is
// reported offline until a full threshold has elapsed since startup, so a
// deploy cannot manufacture offline notifications for healthy displays.
let graceUntil = now() + offlineThresholdMs;
let heartbeatCount = 0;
const conditionKey = (clientId) => `display:${clientId}`;
function warn(error, message) {
try {
logger.warn?.({ error }, message);
} catch {
// Telemetry logging must never break heartbeat ingestion.
}
}
function notify(event) {
try {
onStatusChanged(event);
} catch (error) {
warn(error, "Display status callback failed");
}
}
function isOffline(row, at) {
return at - row.last_seen_at >= offlineThresholdMs;
}
function publicClient(row, at) {
return {
clientId: row.client_id,
outputId: row.output_id,
status: isOffline(row, at) ? "offline" : "online",
firstSeenAt: new Date(row.first_seen_at).toISOString(),
lastSeenAt: new Date(row.last_seen_at).toISOString(),
activePlaylistId: row.active_playlist_id,
playlistRevision: row.playlist_revision,
playbackRevision: row.playback_revision,
currentEntryId: row.current_entry_id,
currentAssetId: row.current_asset_id,
cachedAssetCount: row.cached_asset_count,
playableAssetCount: row.playable_asset_count,
mostRecentError: parseDisplayError(row.last_error),
};
}
function openOffline(row, at) {
if (!notifications) return false;
const result = notifications.observeFailure(conditionKey(row.client_id), {
event: "display.offline",
severity: "critical",
message: `Display ${row.client_id} stopped sending heartbeats.`,
context: {
clientId: row.client_id,
outputId: row.output_id,
lastSeenAt: new Date(row.last_seen_at).toISOString(),
activePlaylistId: row.active_playlist_id,
currentEntryId: row.current_entry_id,
currentAssetId: row.current_asset_id,
},
});
if (result.opened) {
notify({
reason: "offline",
...publicClient(row, at),
status: "offline",
});
}
return result.opened;
}
function recoverOffline(row, at) {
if (!notifications) return false;
const result = notifications.observeRecovery(conditionKey(row.client_id), {
event: "display.recovered",
message: `Display ${row.client_id} is sending heartbeats again.`,
context: {
clientId: row.client_id,
outputId: row.output_id,
recoveredAt: new Date(at).toISOString(),
},
});
return result.recovered;
}
// Reconciles the one display-error condition this client currently reports
// against whatever it had open before. Phase 4 deduplication stays
// authoritative: a persisting error is never re-notified, and a resolved one
// recovers exactly once.
function reconcileErrorIncidents(clientId, row, at) {
if (!notifications) return;
const parsed = parseDisplayError(row.last_error);
const structured =
parsed && typeof parsed === "object" && DISPLAY_ERROR_SEVERITY[parsed.kind]
? parsed
: null;
const desired = structured ? errorConditionKey(clientId, structured) : null;
const offlineKey = conditionKey(clientId);
for (const key of notifications.openConditionKeys(`display:${clientId}:`)) {
// The display-offline condition is a separate concern and is never
// touched here.
if (key === offlineKey || key === desired) continue;
notifications.observeRecovery(key, {
event: "display.error_cleared",
message: `Display ${clientId} no longer reports that error.`,
context: { clientId, recoveredAt: new Date(at).toISOString() },
});
}
if (!structured) return;
notifications.observeFailure(desired, {
event: `display.${structured.kind}`,
severity: DISPLAY_ERROR_SEVERITY[structured.kind],
message: `Display ${clientId} ${DISPLAY_ERROR_MESSAGES[structured.kind]}.`,
context: {
clientId,
outputId: row.output_id,
kind: structured.kind,
assetId: structured.assetId ?? null,
entryId: structured.entryId ?? null,
detail: structured.message ?? null,
},
});
}
function heartbeat(payload) {
const telemetry = validateHeartbeat(payload);
const at = now();
const existing = findClient.get(telemetry.clientId);
const record = { ...telemetry, at };
if (existing) {
updateClient.run(record);
} else {
insertClient.run(record);
}
heartbeatCount += 1;
const row = findClient.get(telemetry.clientId);
const wasOffline = existing ? isOffline(existing, at) : false;
const recovered = recoverOffline(row, at);
reconcileErrorIncidents(telemetry.clientId, row, at);
const errorChanged = (existing?.last_error ?? null) !== telemetry.lastError;
if (!existing) {
notify({ reason: "first_seen", ...publicClient(row, at) });
} else if (recovered || wasOffline) {
notify({ reason: "recovered", ...publicClient(row, at) });
} else if (errorChanged) {
notify({ reason: "error_changed", ...publicClient(row, at) });
}
// A steady heartbeat is intentionally silent: telemetry must not create
// realtime churn, and it never touches playback or playlist revisions.
return publicClient(row, at);
}
function sweep() {
const at = now();
if (at < graceUntil) return 0;
let opened = 0;
for (const row of listClients.all()) {
if (!isOffline(row, at)) continue;
if (openOffline(row, at)) opened += 1;
}
return opened;
}
function schedule() {
if (!running) return;
if (timerHandle !== null) {
clearTimeoutFn(timerHandle);
timerHandle = null;
}
const at = now();
const rows = listClients.all();
// Wake only when the next display could actually cross the threshold
// instead of polling on a fixed interval.
const deadlines = rows
.map((row) => row.last_seen_at + offlineThresholdMs)
.filter((deadline) => deadline > at);
const next = Math.max(
graceUntil,
deadlines.length > 0 ? Math.min(...deadlines) : at + offlineThresholdMs,
);
timerHandle = setTimeoutFn(() => {
timerHandle = null;
try {
sweep();
} catch (error) {
warn(error, "Display offline sweep failed");
}
schedule();
}, Math.max(0, next - at));
timerHandle?.unref?.();
}
function start() {
running = true;
graceUntil = now() + offlineThresholdMs;
schedule();
}
function stop() {
running = false;
if (timerHandle !== null) {
clearTimeoutFn(timerHandle);
timerHandle = null;
}
}
function list() {
const at = now();
return listClients.all().map((row) => publicClient(row, at));
}
function status() {
const at = now();
const rows = listClients.all();
const online = rows.filter((row) => !isOffline(row, at)).length;
return {
status: rows.length === 0 ? "none_seen" : online > 0 ? "ok" : "degraded",
known: rows.length,
online,
offline: rows.length - online,
heartbeatCount,
offlineThresholdMs,
};
}
return {
heartbeat,
list,
schedule,
start,
status,
stop,
sweep,
};
}
module.exports = {
DEFAULT_OFFLINE_THRESHOLD_MS,
HEARTBEAT_PROTOCOL_VERSION,
MAX_ERROR_LENGTH,
DISPLAY_ERROR_SEVERITY,
createDisplayService,
errorConditionKey,
parseDisplayError,
validateHeartbeat,
};
+187
View File
@@ -0,0 +1,187 @@
const express = require("express");
const { checkDatabase } = require("../database");
const { redactedConfig } = require("../config");
function databaseHealth(database) {
try {
checkDatabase(database);
return { status: "ok" };
} catch (error) {
return {
status: "error",
message: "SQLite is unavailable",
};
}
}
function storageHealth(storage) {
if (!storage) {
return {
status: "degraded",
checkedAt: null,
lastSuccessAt: null,
code: "STORAGE_NOT_INITIALIZED",
message: "Object storage has not been initialized",
};
}
try {
const result = storage.health();
if (result?.status === "healthy") {
return {
status: "healthy",
checkedAt: result.checkedAt || null,
lastSuccessAt: result.lastSuccessAt || result.checkedAt || null,
};
}
return {
status: "degraded",
checkedAt: result?.checkedAt || null,
lastSuccessAt: result?.lastSuccessAt || null,
code:
result?.code === "STORAGE_NOT_CHECKED"
? "STORAGE_NOT_CHECKED"
: "STORAGE_UNAVAILABLE",
message:
result?.code === "STORAGE_NOT_CHECKED"
? "Object storage has not been checked"
: "Object storage is unavailable",
};
} catch {
// A health endpoint must remain available when its dependency is not.
}
return {
status: "degraded",
checkedAt: null,
lastSuccessAt: null,
code: "STORAGE_UNAVAILABLE",
message: "Object storage is unavailable",
};
}
function createHealthRouter({ database, lifecycle, migrations, storage }) {
const router = express.Router();
router.get("/live", (request, response) => {
response.json({ status: "ok" });
});
router.get("/ready", async (request, response) => {
const databaseCheck = databaseHealth(database);
const storageCheck = storageHealth(storage);
const migrationsCurrent = migrations.pendingVersions.length === 0;
const ready =
lifecycle.ready && databaseCheck.status === "ok" && migrationsCurrent;
response.status(ready ? 200 : 503).json({
status: ready ? "ready" : "not_ready",
checks: {
database: databaseCheck.status,
migrations: migrationsCurrent ? "current" : "pending",
storage: storageCheck.status,
},
});
});
return router;
}
// Integration status is reported defensively: an integration that is down or
// throwing must still produce a status document rather than a 500.
function integrationStatus(service, fallbackConfigured) {
if (!service) {
return { status: "not_started", configured: fallbackConfigured };
}
try {
return service.status();
} catch {
return { status: "unknown", configured: fallbackConfigured };
}
}
function playbackStatus(playbackService) {
if (!playbackService) return { status: "not_started" };
try {
const snapshot = playbackService.getSnapshot();
return {
status: "ok",
mode: snapshot.mode,
transportState: snapshot.transportState,
activePlaylistId: snapshot.activePlaylistId ?? null,
playbackRevision: snapshot.playbackRevision,
hasPlayableTarget: Boolean(snapshot.normalTarget),
};
} catch {
return { status: "unknown" };
}
}
function createDetailedHealthRouter({
config,
database,
displayService,
integrations = {},
lifecycle,
migrations,
playbackService,
storage,
}) {
const router = express.Router();
router.get("/", async (request, response) => {
const databaseCheck = databaseHealth(database);
const storageCheck = storageHealth(storage);
const ready =
lifecycle.ready &&
databaseCheck.status === "ok" &&
migrations.pendingVersions.length === 0;
const configuration = redactedConfig(config);
const mqttCheck = integrationStatus(
integrations.mqtt,
configuration.mqtt.configured,
);
const notificationCheck = integrationStatus(
integrations.notifications,
configuration.notifications.configured,
);
const displayCheck = integrationStatus(displayService, true);
// External integrations degrade the reported status. They never make the
// API unready, because signage playback does not depend on them.
const degraded =
storageCheck.status !== "healthy" ||
mqttCheck.status === "disconnected" ||
notificationCheck.status === "degraded";
const status = !ready ? "not_ready" : degraded ? "degraded" : "ok";
response.status(ready ? 200 : 503).json({
status,
configuration,
services: {
database: {
...databaseCheck,
currentSchemaVersion: migrations.currentVersion,
latestSchemaVersion: migrations.latestAvailableVersion,
},
storage: {
...storageCheck,
configured: configuration.storage.configured,
},
mqtt: mqttCheck,
notifications: notificationCheck,
playback: playbackStatus(playbackService),
displays: displayCheck,
},
});
});
return router;
}
module.exports = {
createDetailedHealthRouter,
createHealthRouter,
databaseHealth,
storageHealth,
};
+330
View File
@@ -0,0 +1,330 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const sharp = require("sharp");
const SUPPORTED_FORMATS = Object.freeze(new Set(["jpeg", "png", "webp"]));
const CONTENT_TYPES = Object.freeze({
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
});
const BLACK = Object.freeze({ r: 0, g: 0, b: 0, alpha: 1 });
const DEFAULT_IMAGE_DERIVATIVE_OPTIONS = Object.freeze({
display: Object.freeze({ maxWidth: 1920, maxHeight: 1080, quality: 82 }),
thumbnail: Object.freeze({ maxWidth: 480, maxHeight: 270, quality: 72 }),
});
class ImageProcessingError extends Error {
constructor(code, message, { statusCode = 422, details, cause } = {}) {
super(message, cause ? { cause } : undefined);
this.name = "ImageProcessingError";
this.code = code;
this.statusCode = statusCode;
if (details !== undefined) {
this.details = details;
}
}
}
function requireInteger(value, name, { min, max } = {}) {
if (!Number.isSafeInteger(value)) {
throw new TypeError(`${name} must be an integer`);
}
if (min !== undefined && value < min) {
throw new RangeError(`${name} must be at least ${min}`);
}
if (max !== undefined && value > max) {
throw new RangeError(`${name} must be at most ${max}`);
}
return value;
}
function normalizeTargetOptions(target, name, defaults) {
if (
target !== undefined &&
(target === null || typeof target !== "object" || Array.isArray(target))
) {
throw new TypeError(`${name} image options must be an object`);
}
const configured = { ...defaults, ...target };
return Object.freeze({
maxWidth: requireInteger(configured.maxWidth, `${name}.maxWidth`, { min: 1 }),
maxHeight: requireInteger(configured.maxHeight, `${name}.maxHeight`, { min: 1 }),
quality: requireInteger(configured.quality, `${name}.quality`, {
min: 1,
max: 100,
}),
});
}
function normalizeOptions(options) {
if (options === null || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Image processing options are required");
}
return Object.freeze({
maxInputPixels: requireInteger(
options.maxInputPixels,
"maxInputPixels",
{ min: 1 },
),
display: normalizeTargetOptions(
options.display,
"display",
DEFAULT_IMAGE_DERIVATIVE_OPTIONS.display,
),
thumbnail: normalizeTargetOptions(
options.thumbnail,
"thumbnail",
DEFAULT_IMAGE_DERIVATIVE_OPTIONS.thumbnail,
),
});
}
function sha256(buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
async function hashFile(filename) {
const hash = crypto.createHash("sha256");
let byteSize = 0;
try {
for await (const chunk of fs.createReadStream(filename)) {
byteSize += chunk.length;
hash.update(chunk);
}
} catch (error) {
throw new ImageProcessingError(
"IMAGE_INPUT_UNREADABLE",
"The uploaded image could not be read",
{ statusCode: 422, cause: error },
);
}
return { byteSize, sha256: hash.digest("hex") };
}
async function describeInput(input) {
if (Buffer.isBuffer(input)) {
return {
sharpInput: input,
byteSize: input.length,
sha256: sha256(input),
};
}
if (typeof input !== "string" || input.length === 0) {
throw new ImageProcessingError(
"INVALID_IMAGE_INPUT",
"Image input must be a Buffer or temporary file path",
{ statusCode: 400 },
);
}
let stats;
try {
stats = await fs.promises.stat(input);
} catch (error) {
throw new ImageProcessingError(
"IMAGE_INPUT_UNREADABLE",
"The uploaded image could not be read",
{ statusCode: 422, cause: error },
);
}
if (!stats.isFile()) {
throw new ImageProcessingError(
"INVALID_IMAGE_INPUT",
"Image input path must identify a regular file",
{ statusCode: 400 },
);
}
return {
sharpInput: input,
...(await hashFile(input)),
};
}
function decodeError(error, maxInputPixels) {
if (error instanceof ImageProcessingError) {
return error;
}
if (/pixel limit/i.test(error?.message || "")) {
return new ImageProcessingError(
"IMAGE_PIXEL_LIMIT_EXCEEDED",
"The uploaded image exceeds the configured decoded-pixel limit",
{
statusCode: 413,
details: { maxInputPixels },
cause: error,
},
);
}
return new ImageProcessingError(
"MALFORMED_IMAGE",
"The uploaded file is not a valid decodable image",
{ statusCode: 422, cause: error },
);
}
async function readMetadata(input, maxInputPixels) {
try {
return await sharp(input, {
animated: true,
failOn: "error",
limitInputPixels: maxInputPixels,
}).metadata();
} catch (error) {
throw decodeError(error, maxInputPixels);
}
}
function validateMetadata(metadata) {
const pages = metadata.pages ?? 1;
if (pages > 1) {
const hasAnimationTiming =
metadata.loop !== undefined ||
(Array.isArray(metadata.delay) && metadata.delay.length > 0);
throw new ImageProcessingError(
hasAnimationTiming
? "ANIMATED_IMAGE_NOT_SUPPORTED"
: "MULTIPAGE_IMAGE_NOT_SUPPORTED",
hasAnimationTiming
? "Animated images are not supported"
: "Multipage images are not supported",
{ statusCode: 422, details: { pages } },
);
}
if (!SUPPORTED_FORMATS.has(metadata.format)) {
throw new ImageProcessingError(
"UNSUPPORTED_IMAGE_FORMAT",
"Only still JPEG, PNG, and WebP images are supported",
{
statusCode: 415,
details: {
detectedFormat: metadata.format || null,
supportedFormats: [...SUPPORTED_FORMATS],
},
},
);
}
if (
!Number.isSafeInteger(metadata.width) ||
metadata.width < 1 ||
!Number.isSafeInteger(metadata.height) ||
metadata.height < 1
) {
throw new ImageProcessingError(
"MALFORMED_IMAGE",
"The uploaded image does not contain valid dimensions",
{ statusCode: 422 },
);
}
}
async function generateDerivative(input, target, maxInputPixels) {
let output;
try {
output = await sharp(input, {
failOn: "error",
limitInputPixels: maxInputPixels,
})
.autoOrient()
.flatten({ background: BLACK })
.resize({
width: target.maxWidth,
height: target.maxHeight,
fit: "inside",
withoutEnlargement: true,
})
.toColourspace("srgb")
.webp({ quality: target.quality, effort: 4 })
.toBuffer({ resolveWithObject: true });
} catch (error) {
throw decodeError(error, maxInputPixels);
}
return {
buffer: output.data,
sha256: sha256(output.data),
format: "webp",
contentType: CONTENT_TYPES.webp,
byteSize: output.data.length,
width: output.info.width,
height: output.info.height,
};
}
function originalMetadata(metadata, inputDescription) {
const orientedDimensions = metadata.autoOrient || {
width: metadata.width,
height: metadata.height,
};
return {
sha256: inputDescription.sha256,
format: metadata.format,
contentType: CONTENT_TYPES[metadata.format],
byteSize: inputDescription.byteSize,
// Width and height describe how the original should appear after applying
// its source orientation. The encoded dimensions remain available for
// diagnostics without implying that the archived bytes were rewritten.
width: orientedDimensions.width,
height: orientedDimensions.height,
encodedWidth: metadata.width,
encodedHeight: metadata.height,
orientation: metadata.orientation ?? null,
pages: metadata.pages ?? 1,
channels: metadata.channels,
hasAlpha: Boolean(metadata.hasAlpha),
};
}
function createImageProcessor(options) {
const config = normalizeOptions(options);
async function process(input) {
const inputDescription = await describeInput(input);
const metadata = await readMetadata(
inputDescription.sharpInput,
config.maxInputPixels,
);
validateMetadata(metadata);
const [display, thumbnail] = await Promise.all([
generateDerivative(
inputDescription.sharpInput,
config.display,
config.maxInputPixels,
),
generateDerivative(
inputDescription.sharpInput,
config.thumbnail,
config.maxInputPixels,
),
]);
return {
original: originalMetadata(metadata, inputDescription),
display,
thumbnail,
};
}
return Object.freeze({ process });
}
module.exports = {
DEFAULT_IMAGE_DERIVATIVE_OPTIONS,
ImageProcessingError,
createImageProcessor,
};
+51
View File
@@ -0,0 +1,51 @@
const path = require("node:path");
require("dotenv").config({ path: path.resolve(__dirname, "../../.env") });
const { loadConfig, redactedConfig } = require("./config");
const { createRuntime } = require("./runtime");
async function startApplication({ env = process.env, logger = console } = {}) {
const config = loadConfig(env);
const runtime = createRuntime({ config, logger });
const address = await runtime.start();
const displayedHost = address.family === "IPv6" ? `[${address.address}]` : address.address;
logger.info?.(
`Announcements API listening on http://${displayedHost}:${address.port}`,
);
logger.info?.("Configuration loaded", redactedConfig(config));
let shutdownStarted = false;
const shutdown = async (signal) => {
if (shutdownStarted) return;
shutdownStarted = true;
try {
await runtime.stop(signal);
} catch (error) {
logger.error?.("Graceful shutdown failed", error);
process.exitCode = 1;
}
};
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
return runtime;
}
async function main() {
try {
await startApplication();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
if (require.main === module) {
main();
}
module.exports = {
startApplication,
};
+106
View File
@@ -0,0 +1,106 @@
const ELIGIBILITY_REASONS = Object.freeze({
DISABLED: "disabled",
SCHEDULED_FUTURE: "scheduled_future",
SCHEDULED_ENDED: "scheduled_ended",
ASSET_NOT_READY: "asset_not_ready",
ASSET_EXPIRED: "asset_expired",
});
function read(object, snakeCaseName, camelCaseName) {
if (object && Object.hasOwn(object, snakeCaseName)) {
return object[snakeCaseName];
}
return object?.[camelCaseName];
}
function validateNow(nowMs) {
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
throw new TypeError("nowMs must be a non-negative, safe epoch-millisecond integer");
}
}
function evaluateEntryEligibility(entry, asset, nowMs) {
validateNow(nowMs);
const enabled = read(entry, "enabled", "enabled");
if (enabled !== true && enabled !== 1) {
return { playable: false, reason: ELIGIBILITY_REASONS.DISABLED };
}
const scheduleStartAt = read(entry, "schedule_start_at", "scheduleStartAt");
if (scheduleStartAt != null && nowMs < scheduleStartAt) {
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE };
}
const scheduleEndAt = read(entry, "schedule_end_at", "scheduleEndAt");
if (scheduleEndAt != null && nowMs >= scheduleEndAt) {
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED };
}
if (read(asset, "status", "status") !== "ready") {
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY };
}
const expirationAt = read(asset, "expiration_at", "expirationAt");
if (expirationAt != null && nowMs >= expirationAt) {
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED };
}
return { playable: true, reason: null };
}
function resolveEntrySettings(entry, appSettings) {
const durationOverrideMs = read(
entry,
"duration_override_ms",
"durationOverrideMs",
);
const backgroundModeOverride = read(
entry,
"background_mode_override",
"backgroundModeOverride",
);
const backgroundColorOverride = read(
entry,
"background_color_override",
"backgroundColorOverride",
);
const backgroundDarkenedOverride = read(
entry,
"background_darkened_override",
"backgroundDarkenedOverride",
);
return {
durationMs:
durationOverrideMs ??
read(appSettings, "default_duration_ms", "defaultDurationMs"),
presentation: {
backgroundMode:
backgroundModeOverride ??
read(appSettings, "default_background_mode", "defaultBackgroundMode"),
backgroundColor:
backgroundColorOverride ??
read(
appSettings,
"default_solid_background_color",
"defaultSolidBackgroundColor",
),
backgroundDarkened: Boolean(
backgroundDarkenedOverride ??
read(
appSettings,
"default_background_darkened",
"defaultBackgroundDarkened",
),
),
blurPx: read(appSettings, "default_blur_px", "defaultBlurPx"),
},
};
}
module.exports = {
ELIGIBILITY_REASONS,
evaluateEntryEligibility,
resolveEntrySettings,
};
+1
View File
@@ -0,0 +1 @@
module.exports = require("./eligibility");
+314
View File
@@ -0,0 +1,314 @@
const PLAYBACK_STATE_VERSION = 1;
// Commands that change durable state are delivered at least once. next and
// previous are not idempotent and carry no command ID, so a redelivered copy
// would silently skip a slide; they stay at QoS 0 deliberately.
const COMMANDS = Object.freeze([
{ name: "mode", suffix: "mode", qos: 1 },
{ name: "pause", suffix: "pause", qos: 1 },
{ name: "resume", suffix: "resume", qos: 1 },
{ name: "reload", suffix: "reload", qos: 1 },
{ name: "static", suffix: "static", qos: 1 },
{ name: "next", suffix: "next", qos: 0 },
{ name: "previous", suffix: "previous", qos: 0 },
]);
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function transportOf(snapshot) {
if (snapshot.staticTarget) return "static";
if (snapshot.transport?.rotationPaused) return "paused";
return snapshot.normalTarget ? "rotating" : "idle";
}
function compactState(snapshot) {
return {
version: PLAYBACK_STATE_VERSION,
mode: snapshot.mode,
transport: transportOf(snapshot),
activePlaylistId: snapshot.activePlaylistId ?? null,
currentEntryId: snapshot.currentEntryId ?? null,
currentAssetId: snapshot.normalTarget?.assetId ?? null,
staticAssetId: snapshot.staticTarget?.assetId ?? null,
revision: snapshot.playbackRevision,
};
}
function createMqttService({
config,
playbackService,
connect,
logger = console,
} = {}) {
if (!playbackService) throw new TypeError("playbackService is required");
const settings = config?.mqtt || {};
const prefix = settings.topicPrefix || "announcements";
const topic = (suffix) => `${prefix}/${suffix}`;
const availabilityTopic = topic("availability");
const modeStateTopic = topic("mode/state");
const playbackStateTopic = topic("playback/state");
const configured = Boolean(settings.url);
let client = null;
let connected = false;
let connectionCount = 0;
let lastConnectedAt = null;
let lastErrorAt = null;
let lastErrorMessage = null;
let commandCount = 0;
let stopping = false;
function warn(error, message, details) {
try {
logger.warn?.(`${message}${details ? ` ${JSON.stringify(details)}` : ""}`, error);
} catch {
// Logging failures must never propagate into the broker callbacks.
}
}
function publish(topicName, payload, options) {
if (!client || !connected) return false;
try {
client.publish(topicName, payload, options, (error) => {
if (error) warn(error, "MQTT publish failed", { topic: topicName });
});
return true;
} catch (error) {
warn(error, "MQTT publish threw", { topic: topicName });
return false;
}
}
// Retained authoritative state is only ever published on dedicated state
// topics so a subscriber can never mistake an echo for a new command.
function publishState(snapshot) {
if (!snapshot) return;
publish(modeStateTopic, snapshot.mode, { qos: 1, retain: true });
publish(playbackStateTopic, JSON.stringify(compactState(snapshot)), {
qos: 1,
retain: true,
});
}
function publishCurrentState() {
try {
publishState(playbackService.getSnapshot());
} catch (error) {
warn(error, "MQTT could not read the authoritative playback snapshot");
}
}
function handleMode(payload) {
const value = payload.trim().toLowerCase();
if (value !== "on" && value !== "off") {
warn(null, "Ignoring MQTT mode command with an unsupported payload", {
payload: value.slice(0, 40),
});
return false;
}
playbackService.setMode(value);
return true;
}
function handleStatic(payload) {
const assetId = payload.trim();
if (!UUID_PATTERN.test(assetId)) {
warn(null, "Ignoring MQTT static command that is not a LibraryAsset UUID", {
payload: assetId.slice(0, 60),
});
return false;
}
playbackService.enterStatic(assetId);
return true;
}
const handlers = Object.freeze({
mode: handleMode,
static: handleStatic,
next: () => (playbackService.next(), true),
previous: () => (playbackService.previous(), true),
pause: () => (playbackService.pause(), true),
resume: () => (playbackService.resume(), true),
reload: () => (playbackService.reload(), true),
});
const byTopic = new Map(
COMMANDS.map((command) => [topic(command.suffix), command]),
);
function handleMessage(topicName, buffer) {
const command = byTopic.get(topicName);
if (!command) {
warn(null, "Ignoring MQTT message on an unknown topic", { topic: topicName });
return;
}
let payload = "";
try {
payload = buffer ? buffer.toString("utf8") : "";
} catch (error) {
warn(error, "Ignoring MQTT message with an undecodable payload", {
topic: topicName,
});
return;
}
try {
// Every command runs through the one authoritative playback service.
// A rejected command leaves playback exactly as it was.
if (handlers[command.name](payload)) commandCount += 1;
} catch (error) {
warn(error, "MQTT command was rejected by the playback service", {
topic: topicName,
code: error?.code,
});
}
}
function subscribeAll() {
if (!client) return;
for (const command of COMMANDS) {
client.subscribe(topic(command.suffix), { qos: command.qos }, (error) => {
if (error) warn(error, "MQTT subscribe failed", { topic: topic(command.suffix) });
});
}
}
function handleConnect() {
connected = true;
connectionCount += 1;
lastConnectedAt = new Date().toISOString();
logger.info?.(`MQTT connected to the configured broker as ${prefix}`);
// Reconnect must restore both the subscriptions and the retained state a
// broker restart would otherwise have dropped.
publish(availabilityTopic, "online", { qos: 1, retain: true });
subscribeAll();
publishCurrentState();
}
function start() {
if (!configured) {
logger.info?.("MQTT is not configured; the integration stays disabled");
return null;
}
if (client) return client;
const factory = connect || require("mqtt").connect;
client = factory(settings.url, {
username: settings.username || undefined,
password: settings.password || undefined,
reconnectPeriod: settings.reconnectPeriodMs ?? 5_000,
connectTimeout: settings.connectTimeoutMs ?? 10_000,
clean: true,
// The broker publishes this for us if the API disappears without a
// clean disconnect, which is exactly the case a client cannot report.
will: {
topic: availabilityTopic,
payload: "offline",
qos: 1,
retain: true,
},
});
client.on("connect", handleConnect);
client.on("message", handleMessage);
client.on("reconnect", () => {
connected = false;
});
client.on("close", () => {
connected = false;
});
client.on("offline", () => {
connected = false;
});
client.on("error", (error) => {
connected = false;
lastErrorAt = new Date().toISOString();
lastErrorMessage = error?.message || "MQTT error";
// A broker outage degrades the integration only. Playback, HTTP, and
// the display protocol continue untouched and the client reconnects.
warn(error, "MQTT connection error; the integration is degraded");
});
return client;
}
async function stop() {
stopping = true;
if (!client) return;
const active = client;
client = null;
try {
if (connected) {
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
const timer = setTimeout(finish, 1_000);
timer.unref?.();
// A clean shutdown retracts availability itself; the will is only
// for the unexpected case.
active.publish(availabilityTopic, "offline", { qos: 1, retain: true }, () => {
clearTimeout(timer);
finish();
});
});
for (const command of COMMANDS) {
try {
active.unsubscribe(topic(command.suffix));
} catch {
// Shutdown continues even if the broker already went away.
}
}
}
} catch (error) {
warn(error, "MQTT shutdown publication failed");
}
connected = false;
try {
await new Promise((resolve) => {
const timer = setTimeout(resolve, 1_000);
timer.unref?.();
active.end(false, {}, () => {
clearTimeout(timer);
resolve();
});
});
} catch (error) {
warn(error, "MQTT client did not close cleanly");
}
}
function status() {
return {
configured,
status: !configured ? "disabled" : connected ? "connected" : "disconnected",
connected,
topicPrefix: prefix,
connectionCount,
commandCount,
lastConnectedAt,
lastErrorAt,
lastErrorMessage,
stopping,
};
}
return {
availabilityTopic,
commandTopics: COMMANDS.map((command) => topic(command.suffix)),
compactState,
modeStateTopic,
playbackStateTopic,
publishState,
start,
status,
stop,
};
}
module.exports = {
COMMANDS,
PLAYBACK_STATE_VERSION,
createMqttService,
};
+88
View File
@@ -0,0 +1,88 @@
const STORAGE_CONDITION = "storage";
const NO_PLAYABLE_CONDITION = "playback.no_playable";
// Storage health already debounces individual request failures into one
// coherent status, so an outage observed by many requests stays one incident.
function observeStorageHealth(notifications, health) {
if (!notifications || !health) return null;
if (health.status === "healthy") {
return notifications.observeRecovery(STORAGE_CONDITION, {
event: "storage.recovered",
message: "Announcement storage is reachable again.",
context: { checkedAt: health.checkedAt || null },
});
}
if (health.status === "degraded" && health.code === "STORAGE_UNAVAILABLE") {
return notifications.observeFailure(STORAGE_CONDITION, {
event: "storage.unavailable",
severity: "critical",
message: "Announcement storage is unavailable.",
context: {
checkedAt: health.checkedAt || null,
lastSuccessAt: health.lastSuccessAt || null,
},
});
}
// STORAGE_NOT_CHECKED is startup state, not an outage worth reporting.
return null;
}
// A static override can keep the screen filled while the normal playlist is
// empty. That is still an operator-actionable condition, so static does not
// suppress it.
function observePlayback(notifications, snapshot) {
if (!notifications || !snapshot) return null;
if (snapshot.activePlaylistId === null || snapshot.activePlaylistId === undefined) {
// No playlist has been activated yet; there is nothing to repair.
return notifications.observeRecovery(NO_PLAYABLE_CONDITION, {
event: "playback.playable",
message: "Announcement playback has a playable slide again.",
context: { activePlaylistId: null },
});
}
if (snapshot.normalTarget) {
return notifications.observeRecovery(NO_PLAYABLE_CONDITION, {
event: "playback.playable",
message: "Announcement playback has a playable slide again.",
context: {
activePlaylistId: snapshot.activePlaylistId,
currentEntryId: snapshot.currentEntryId ?? null,
},
});
}
return notifications.observeFailure(NO_PLAYABLE_CONDITION, {
event: "playback.no_playable_slides",
severity: "warning",
message: "The active announcement playlist has no playable slides.",
context: {
activePlaylistId: snapshot.activePlaylistId,
staticActive: Boolean(snapshot.staticTarget),
mode: snapshot.mode,
},
});
}
// An individual failed upload has no automatic recovery: the operator deletes
// it or uploads replacement bytes. One incident per asset means one warning,
// never a repeating one.
function observeAssetProcessingFailure(notifications, { assetId, displayName, message }) {
if (!notifications || !assetId) return null;
return notifications.observeFailure(`asset:${assetId}`, {
event: "asset.processing_failed",
severity: "warning",
message: `Image processing failed for ${displayName || assetId}.`,
context: {
assetId,
displayName: displayName || null,
processingError: notifications.boundedError(message),
},
});
}
module.exports = {
NO_PLAYABLE_CONDITION,
STORAGE_CONDITION,
observeAssetProcessingFailure,
observePlayback,
observeStorageHealth,
};
+367
View File
@@ -0,0 +1,367 @@
const crypto = require("node:crypto");
const PAYLOAD_VERSION = 1;
const MAX_ERROR_LENGTH = 500;
// Immediate, then a capped schedule. Index is the attempt that just failed, so
// the first retry waits 30s and every attempt past the table waits 30 minutes.
const RETRY_BACKOFF_MS = Object.freeze([
30_000,
60_000,
300_000,
900_000,
1_800_000,
]);
const SEVERITIES = Object.freeze(["info", "warning", "critical", "recovery"]);
function boundedError(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > MAX_ERROR_LENGTH
? `${trimmed.slice(0, MAX_ERROR_LENGTH - 1)}…`
: trimmed;
}
function backoffMs(attemptCount) {
if (attemptCount <= 0) return 0;
return RETRY_BACKOFF_MS[
Math.min(attemptCount - 1, RETRY_BACKOFF_MS.length - 1)
];
}
function createNotificationService({
database,
webhookUrl = null,
webhookSecret = null,
webhookTimeoutMs = 10_000,
now = Date.now,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
createId = crypto.randomUUID,
fetchFn = (...args) => fetch(...args),
logger = { warn() {} },
} = {}) {
if (!database) throw new TypeError("database is required");
const findOpen = database.prepare(
"SELECT * FROM notification_incidents WHERE condition_key = ? AND state = 'open'",
);
const lastEpisode = database.prepare(
"SELECT COALESCE(MAX(episode), 0) AS episode FROM notification_incidents WHERE condition_key = ?",
);
const insertIncident = database.prepare(`
INSERT INTO notification_incidents (
id, condition_key, episode, event, severity, state, message,
context_json, opened_at, last_observed_at
) VALUES (
@id, @conditionKey, @episode, @event, @severity, 'open', @message,
@contextJson, @openedAt, @lastObservedAt
)
`);
const observeIncident = database.prepare(
"UPDATE notification_incidents SET last_observed_at = ? WHERE id = ?",
);
const recoverIncident = database.prepare(`
UPDATE notification_incidents
SET state = 'recovered', recovered_at = ?, last_observed_at = ?
WHERE id = ? AND state = 'open'
`);
const insertOutbox = database.prepare(`
INSERT INTO notification_outbox (
id, incident_id, transition, payload_json, attempt_count,
next_attempt_at, created_at
) VALUES (
@id, @incidentId, @transition, @payloadJson, 0, @nextAttemptAt, @createdAt
)
`);
const dueDeliveries = database.prepare(`
SELECT * FROM notification_outbox
WHERE delivered_at IS NULL AND next_attempt_at <= ?
ORDER BY next_attempt_at, created_at
LIMIT 20
`);
const earliestPending = database.prepare(`
SELECT MIN(next_attempt_at) AS next_attempt_at
FROM notification_outbox
WHERE delivered_at IS NULL
`);
const markDelivered = database.prepare(`
UPDATE notification_outbox
SET delivered_at = ?, attempt_count = attempt_count + 1, last_error = NULL
WHERE id = ?
`);
const markFailedDelivery = database.prepare(`
UPDATE notification_outbox
SET attempt_count = attempt_count + 1, next_attempt_at = ?, last_error = ?
WHERE id = ?
`);
const countPending = database.prepare(
"SELECT COUNT(*) AS count FROM notification_outbox WHERE delivered_at IS NULL",
);
const listOpenIncidents = database.prepare(
"SELECT * FROM notification_incidents WHERE state = 'open' ORDER BY opened_at",
);
const configured = Boolean(webhookUrl);
let running = false;
let timerHandle = null;
let delivering = false;
let lastSuccessAt = null;
let lastFailureAt = null;
let lastFailureMessage = null;
let deliveredCount = 0;
function warn(error, message, details) {
try {
logger.warn?.({ error, ...details }, message);
} catch {
// Observability must never break an integration path.
}
}
function payloadFor(incident, transition, at) {
return {
version: PAYLOAD_VERSION,
notificationId: null,
incidentId: incident.id,
event: incident.event,
severity: transition === "recovery" ? "recovery" : incident.severity,
state: transition === "recovery" ? "recovery" : "failure",
message: incident.message,
timestamp: new Date(at).toISOString(),
context: {
conditionKey: incident.condition_key,
episode: incident.episode,
...JSON.parse(incident.context_json || "{}"),
},
};
}
// Delivery work is only queued when a receiver is configured. Without a URL
// the incident history stays complete and useful while nothing retries
// forever against an endpoint that does not exist.
function enqueue(incident, transition, at) {
if (!configured) return null;
const id = createId();
const payload = { ...payloadFor(incident, transition, at), notificationId: id };
try {
insertOutbox.run({
id,
incidentId: incident.id,
transition,
payloadJson: JSON.stringify(payload),
nextAttemptAt: at,
createdAt: at,
});
} catch (error) {
// UNIQUE (incident_id, transition) makes duplicate notifications
// impossible even if a condition is observed repeatedly.
warn(error, "Notification was already queued for this transition", {
incidentId: incident.id,
transition,
});
return null;
}
return id;
}
const openTransaction = database.transaction(
(conditionKey, { event, severity, message, context }, at) => {
const existing = findOpen.get(conditionKey);
if (existing) {
observeIncident.run(at, existing.id);
return { incident: existing, opened: false, notificationId: null };
}
const incident = {
id: createId(),
conditionKey,
episode: lastEpisode.get(conditionKey).episode + 1,
event,
severity,
message,
contextJson: JSON.stringify(context || {}),
openedAt: at,
lastObservedAt: at,
};
insertIncident.run(incident);
const row = findOpen.get(conditionKey);
const notificationId = enqueue(row, "failure", at);
return { incident: row, opened: true, notificationId };
},
);
const recoverTransaction = database.transaction(
(conditionKey, { event, message, context }, at) => {
const existing = findOpen.get(conditionKey);
if (!existing) return { incident: null, recovered: false, notificationId: null };
recoverIncident.run(at, at, existing.id);
const recoveredIncident = {
...existing,
event: event || existing.event,
message: message || existing.message,
context_json: JSON.stringify({
...JSON.parse(existing.context_json || "{}"),
...(context || {}),
}),
};
const notificationId = enqueue(recoveredIncident, "recovery", at);
return { incident: recoveredIncident, recovered: true, notificationId };
},
);
function observeFailure(conditionKey, details) {
if (!SEVERITIES.includes(details?.severity)) {
throw new TypeError(`severity must be one of ${SEVERITIES.join(", ")}`);
}
const at = now();
const result = openTransaction(conditionKey, details, at);
if (result.opened) scheduleDelivery();
return result;
}
function observeRecovery(conditionKey, details = {}) {
const at = now();
const result = recoverTransaction(conditionKey, details, at);
if (result.recovered) scheduleDelivery();
return result;
}
function isOpen(conditionKey) {
return Boolean(findOpen.get(conditionKey));
}
// Lets a caller find the conditions it currently has open, so a changed
// condition can be recovered without inventing a second bookkeeping store.
function openConditionKeys(prefix) {
return listOpenIncidents
.all()
.map((incident) => incident.condition_key)
.filter((key) => (prefix ? key.startsWith(prefix) : true));
}
async function deliverOne(row) {
const at = now();
const headers = { "content-type": "application/json" };
if (webhookSecret) headers["x-announcements-signature"] = webhookSecret;
try {
const response = await fetchFn(webhookUrl, {
method: "POST",
headers,
body: row.payload_json,
signal: AbortSignal.timeout(webhookTimeoutMs),
});
if (response.status >= 200 && response.status < 300) {
markDelivered.run(at, row.id);
deliveredCount += 1;
lastSuccessAt = at;
return true;
}
throw new Error(`Webhook responded with HTTP ${response.status}`);
} catch (error) {
// Every non-2xx and transport failure is retried on the same capped
// schedule; the payload and its notification ID never change.
const attempt = row.attempt_count + 1;
lastFailureAt = at;
lastFailureMessage = boundedError(error?.message) || "Webhook delivery failed";
markFailedDelivery.run(at + backoffMs(attempt), lastFailureMessage, row.id);
warn(error, "Home Assistant webhook delivery failed", {
notificationId: row.id,
attempt,
});
return false;
}
}
async function deliverDue() {
if (!configured || delivering) return 0;
delivering = true;
let processed = 0;
try {
const rows = dueDeliveries.all(now());
for (const row of rows) {
await deliverOne(row);
processed += 1;
}
} finally {
delivering = false;
}
scheduleDelivery();
return processed;
}
function scheduleDelivery() {
if (!configured || !running) return;
if (timerHandle !== null) {
clearTimeoutFn(timerHandle);
timerHandle = null;
}
const next = earliestPending.get()?.next_attempt_at;
if (next === null || next === undefined) return;
const delay = Math.max(0, next - now());
timerHandle = setTimeoutFn(() => {
timerHandle = null;
deliverDue().catch((error) => {
warn(error, "Notification delivery pass failed");
});
}, delay);
timerHandle?.unref?.();
}
function start() {
running = true;
scheduleDelivery();
}
function stop() {
running = false;
if (timerHandle !== null) {
clearTimeoutFn(timerHandle);
timerHandle = null;
}
}
function status() {
const pending = countPending.get().count;
return {
// The webhook URL and secret are configuration, never status output.
configured,
status: !configured ? "disabled" : lastFailureAt && pending > 0 ? "degraded" : "ok",
pendingCount: pending,
deliveredCount,
lastSuccessAt: lastSuccessAt === null ? null : new Date(lastSuccessAt).toISOString(),
lastFailureAt: lastFailureAt === null ? null : new Date(lastFailureAt).toISOString(),
lastFailureMessage,
openIncidents: listOpenIncidents.all().map((incident) => ({
id: incident.id,
conditionKey: incident.condition_key,
event: incident.event,
severity: incident.severity,
openedAt: new Date(incident.opened_at).toISOString(),
lastObservedAt: new Date(incident.last_observed_at).toISOString(),
})),
};
}
return {
boundedError,
configured,
deliverDue,
isOpen,
observeFailure,
openConditionKeys,
observeRecovery,
start,
status,
stop,
};
}
module.exports = {
MAX_ERROR_LENGTH,
PAYLOAD_VERSION,
RETRY_BACKOFF_MS,
backoffMs,
createNotificationService,
};
+29
View File
@@ -0,0 +1,29 @@
class PlaybackError extends Error {
constructor(code, message, { statusCode = 400, details } = {}) {
super(message);
this.name = "PlaybackError";
this.code = code;
this.statusCode = statusCode;
if (details !== undefined) this.details = details;
}
}
function playlistNotFound(playlistId) {
return new PlaybackError("PLAYLIST_NOT_FOUND", "Playlist not found", {
statusCode: 404,
details: { playlistId },
});
}
function assetNotFound(assetId) {
return new PlaybackError("ASSET_NOT_FOUND", "Library asset not found", {
statusCode: 404,
details: { assetId },
});
}
module.exports = {
PlaybackError,
assetNotFound,
playlistNotFound,
};
+7
View File
@@ -0,0 +1,7 @@
const { PlaybackError } = require("./errors");
const { createPlaybackService } = require("./service");
module.exports = {
PlaybackError,
createPlaybackService,
};
+75
View File
@@ -0,0 +1,75 @@
const express = require("express");
function createPlaybackRouter(playbackService) {
const router = express.Router();
router.get("/", (request, response, next) => {
try {
response.json(playbackService.getSnapshot());
} catch (error) {
next(error);
}
});
router.post("/pause", (request, response, next) => {
try {
response.json(playbackService.pause());
} catch (error) {
next(error);
}
});
router.post("/resume", (request, response, next) => {
try {
response.json(playbackService.resume());
} catch (error) {
next(error);
}
});
router.post("/next", (request, response, next) => {
try {
response.json(playbackService.next());
} catch (error) {
next(error);
}
});
router.post("/previous", (request, response, next) => {
try {
response.json(playbackService.previous());
} catch (error) {
next(error);
}
});
router.post("/static", (request, response, next) => {
try {
response.json(playbackService.enterStatic(request.body?.assetId));
} catch (error) {
next(error);
}
});
router.put("/mode", (request, response, next) => {
try {
response.json(playbackService.setMode(request.body?.mode));
} catch (error) {
next(error);
}
});
router.post("/reload", (request, response, next) => {
try {
response.json(playbackService.reload());
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createPlaybackRouter,
};
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
class PlaylistError extends Error {
constructor(code, message, { statusCode = 400, details, cause } = {}) {
super(message, cause ? { cause } : undefined);
this.name = "PlaylistError";
this.code = code;
this.statusCode = statusCode;
if (details !== undefined) {
this.details = details;
}
}
}
function playlistNotFound(playlistId) {
return new PlaylistError("PLAYLIST_NOT_FOUND", "Playlist not found", {
statusCode: 404,
details: { playlistId },
});
}
function playlistEntryNotFound(playlistId, entryId) {
return new PlaylistError("PLAYLIST_ENTRY_NOT_FOUND", "Playlist entry not found", {
statusCode: 404,
details: { playlistId, entryId },
});
}
module.exports = {
PlaylistError,
playlistEntryNotFound,
playlistNotFound,
};
+7
View File
@@ -0,0 +1,7 @@
const { createPlaylistRouter } = require("./routes");
const { createPlaylistService } = require("./service");
module.exports = {
createPlaylistRouter,
createPlaylistService,
};
+143
View File
@@ -0,0 +1,143 @@
const express = require("express");
const { PlaylistError } = require("./errors");
function orderedEntryIds(body) {
if (body === null || typeof body !== "object" || Array.isArray(body)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Playlist order must be an object containing entryIds",
);
}
const fields = Object.keys(body);
if (fields.length !== 1 || fields[0] !== "entryIds") {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Playlist order must contain only entryIds",
{
details: {
fields,
requiredFields: ["entryIds"],
},
},
);
}
return body.entryIds;
}
function createPlaylistRouter(
playlistService,
{ playbackService } = {},
) {
const router = express.Router();
router.get("/", (request, response, next) => {
try {
response.json({ playlists: playlistService.list() });
} catch (error) {
next(error);
}
});
router.post("/", (request, response, next) => {
try {
response.status(201).json(playlistService.create(request.body));
} catch (error) {
next(error);
}
});
router.get("/:playlistId", (request, response, next) => {
try {
response.json(playlistService.get(request.params.playlistId));
} catch (error) {
next(error);
}
});
router.patch("/:playlistId", (request, response, next) => {
try {
response.json(
playlistService.update(request.params.playlistId, request.body),
);
} catch (error) {
next(error);
}
});
router.delete("/:playlistId", (request, response, next) => {
try {
response.json(playlistService.remove(request.params.playlistId));
} catch (error) {
next(error);
}
});
router.post("/:playlistId/entries", (request, response, next) => {
try {
response
.status(201)
.json(playlistService.addEntry(request.params.playlistId, request.body));
} catch (error) {
next(error);
}
});
router.patch("/:playlistId/entries/:entryId", (request, response, next) => {
try {
response.json(
playlistService.updateEntry(
request.params.playlistId,
request.params.entryId,
request.body,
),
);
} catch (error) {
next(error);
}
});
router.delete("/:playlistId/entries/:entryId", (request, response, next) => {
try {
response.json(
playlistService.removeEntry(
request.params.playlistId,
request.params.entryId,
),
);
} catch (error) {
next(error);
}
});
router.put("/:playlistId/entries/order", (request, response, next) => {
try {
response.json(
playlistService.reorderEntries(
request.params.playlistId,
orderedEntryIds(request.body),
),
);
} catch (error) {
next(error);
}
});
router.post("/:playlistId/activate", (request, response, next) => {
try {
if (!playbackService) {
throw new Error("The playback service is required for playlist activation");
}
const result = playbackService.activatePlaylist(request.params.playlistId);
response.json(result);
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createPlaylistRouter,
orderedEntryIds,
};
+884
View File
@@ -0,0 +1,884 @@
const crypto = require("node:crypto");
const {
evaluateEntryEligibility,
resolveEntrySettings,
} = require("../lifecycle");
const {
PlaylistError,
playlistEntryNotFound,
playlistNotFound,
} = require("./errors");
const MIN_DURATION_MS = 1_000;
const MAX_DURATION_MS = 3_600_000;
const MAX_PLAYLIST_NAME_LENGTH = 200;
const BACKGROUND_MODES = new Set(["blurred", "solid", "black"]);
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
const ENTRY_PATCH_FIELDS = new Set([
"enabled",
"durationOverrideMs",
"backgroundModeOverride",
"backgroundColorOverride",
"backgroundDarkenedOverride",
"scheduleStartAt",
"scheduleEndAt",
]);
function normalizeObject(input, code, message) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new PlaylistError(code, message);
}
return input;
}
function normalizePlaylistName(value) {
if (typeof value !== "string") {
throw new PlaylistError(
"INVALID_PLAYLIST_NAME",
"Playlist name must be a string",
);
}
const normalized = value
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
throw new PlaylistError(
"INVALID_PLAYLIST_NAME",
"Playlist name cannot be empty",
);
}
if ([...normalized].length > MAX_PLAYLIST_NAME_LENGTH) {
throw new PlaylistError(
"INVALID_PLAYLIST_NAME",
`Playlist name cannot exceed ${MAX_PLAYLIST_NAME_LENGTH} characters`,
{ details: { maxLength: MAX_PLAYLIST_NAME_LENGTH } },
);
}
return normalized;
}
function normalizeBoolean(value, field) {
if (typeof value !== "boolean") {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
`${field} must be a boolean`,
{ details: { field } },
);
}
return value ? 1 : 0;
}
function normalizeNullableDuration(value) {
if (value === null) return null;
if (
!Number.isSafeInteger(value) ||
value < MIN_DURATION_MS ||
value > MAX_DURATION_MS
) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
`durationOverrideMs must be null or an integer from ${MIN_DURATION_MS} through ${MAX_DURATION_MS}`,
{
details: {
field: "durationOverrideMs",
min: MIN_DURATION_MS,
max: MAX_DURATION_MS,
},
},
);
}
return value;
}
function normalizeNullableBackgroundMode(value) {
if (value === null) return null;
if (typeof value !== "string" || !BACKGROUND_MODES.has(value)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"backgroundModeOverride must be null, blurred, solid, or black",
{ details: { field: "backgroundModeOverride" } },
);
}
return value;
}
function normalizeNullableColor(value) {
if (value === null) return null;
if (typeof value !== "string" || !HEX_COLOR_PATTERN.test(value)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"backgroundColorOverride must be null or a six-digit hex color",
{ details: { field: "backgroundColorOverride" } },
);
}
return value.toUpperCase();
}
function normalizeNullableDarkening(value) {
if (value === null) return null;
return normalizeBoolean(value, "backgroundDarkenedOverride");
}
function normalizeNullableInstant(value, field) {
if (value === null) return null;
if (Number.isSafeInteger(value) && value >= 0) return value;
if (typeof value === "string" && value.trim()) {
const instant = value.trim();
if (!/([zZ]|[+-]\d{2}:\d{2})$/.test(instant)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
`${field} ISO dates must include Z or a numeric UTC offset`,
{ details: { field } },
);
}
const timestamp = Date.parse(instant);
if (Number.isSafeInteger(timestamp) && timestamp >= 0) return timestamp;
}
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
`${field} must be null, an epoch-millisecond integer, or an ISO date`,
{ details: { field } },
);
}
function validateSchedule(startAt, endAt) {
if (startAt !== null && endAt !== null && startAt >= endAt) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY_SCHEDULE",
"scheduleStartAt must be earlier than scheduleEndAt",
{ details: { scheduleStartAt: startAt, scheduleEndAt: endAt } },
);
}
}
function mapPlaylist(row) {
return {
id: row.id,
name: row.name,
revision: row.revision,
entryCount: Number(row.entry_count || 0),
isActive: Boolean(row.is_active),
isPendingActivation: Boolean(row.is_pending),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mediaUrl(assetId, kind, sha256) {
return sha256
? `/media/assets/${assetId}/${kind}/${sha256}.webp`
: null;
}
function mapResolvedEntry(row, settings, nowMs) {
const eligibility = evaluateEntryEligibility(row, row, nowMs);
const resolved = resolveEntrySettings(row, settings);
return {
id: row.entry_id,
playlistId: row.playlist_id,
assetId: row.asset_id,
enabled: Boolean(row.enabled),
position: row.position,
durationOverrideMs: row.duration_override_ms,
effectiveDurationMs: resolved.durationMs,
backgroundModeOverride: row.background_mode_override,
backgroundColorOverride: row.background_color_override,
backgroundDarkenedOverride:
row.background_darkened_override === null
? null
: Boolean(row.background_darkened_override),
effectivePresentation: resolved.presentation,
scheduleStartAt: row.schedule_start_at,
scheduleEndAt: row.schedule_end_at,
playable: eligibility.playable,
notPlayableReason: eligibility.reason,
eligibility,
asset: {
id: row.asset_id,
displayName: row.asset_display_name,
originalFilename: row.asset_original_filename,
status: row.status,
expirationAt: row.expiration_at,
display: {
width: row.display_width,
height: row.display_height,
url: mediaUrl(row.asset_id, "display", row.display_sha256),
},
thumbnail: {
width: row.thumbnail_width,
height: row.thumbnail_height,
url: mediaUrl(row.asset_id, "thumbnail", row.thumbnail_sha256),
},
},
createdAt: row.entry_created_at,
updatedAt: row.entry_updated_at,
};
}
function createPlaylistService({
database,
now = Date.now,
createId = crypto.randomUUID,
onBeforeChange = () => {},
onChanged = () => {},
}) {
const playlistSelect = `
SELECT p.*,
(SELECT COUNT(*) FROM playlist_entries pe WHERE pe.playlist_id = p.id)
AS entry_count,
EXISTS (
SELECT 1 FROM playback_state ps WHERE ps.active_playlist_id = p.id
) AS is_active,
EXISTS (
SELECT 1 FROM playback_state ps WHERE ps.pending_playlist_id = p.id
) AS is_pending
FROM playlists p
`;
const listPlaylists = database.prepare(
`${playlistSelect} ORDER BY p.name COLLATE NOCASE, p.id`,
);
const findPlaylist = database.prepare(`${playlistSelect} WHERE p.id = ?`);
const findPlaylistByName = database.prepare(
`${playlistSelect} WHERE p.name = ? COLLATE NOCASE`,
);
const insertPlaylist = database.prepare(`
INSERT INTO playlists (id, name, revision, created_at, updated_at)
VALUES (?, ?, 1, ?, ?)
`);
const renamePlaylist = database.prepare(`
UPDATE playlists
SET name = ?, revision = revision + 1, updated_at = ?
WHERE id = ?
`);
const deletePlaylist = database.prepare("DELETE FROM playlists WHERE id = ?");
const bumpPlaylistRevision = database.prepare(`
UPDATE playlists
SET revision = revision + 1, updated_at = ?
WHERE id = ?
`);
const readSettings = database.prepare("SELECT * FROM app_settings WHERE id = 1");
const readPlayback = database.prepare(
"SELECT * FROM playback_state WHERE output_id = 'global'",
);
const resolvedEntries = database.prepare(`
SELECT
pe.id AS entry_id,
pe.playlist_id,
pe.asset_id,
pe.enabled,
pe.duration_override_ms,
pe.background_mode_override,
pe.background_color_override,
pe.background_darkened_override,
pe.schedule_start_at,
pe.schedule_end_at,
pe.position,
pe.created_at AS entry_created_at,
pe.updated_at AS entry_updated_at,
a.display_name AS asset_display_name,
a.original_filename AS asset_original_filename,
a.status,
a.expiration_at,
a.display_sha256,
a.display_width,
a.display_height,
a.thumbnail_sha256,
a.thumbnail_width,
a.thumbnail_height
FROM playlist_entries pe
JOIN assets a ON a.id = pe.asset_id
WHERE pe.playlist_id = ?
ORDER BY pe.position, pe.id
`);
const findEntry = database.prepare(`
SELECT pe.*
FROM playlist_entries pe
WHERE pe.playlist_id = ? AND pe.id = ?
`);
const findEntryByAsset = database.prepare(`
SELECT pe.*
FROM playlist_entries pe
WHERE pe.playlist_id = ? AND pe.asset_id = ?
`);
const findEntryOwner = database.prepare(
"SELECT playlist_id FROM playlist_entries WHERE id = ?",
);
const listEntryIds = database.prepare(`
SELECT id, position
FROM playlist_entries
WHERE playlist_id = ?
ORDER BY position, id
`);
const findAsset = database.prepare("SELECT * FROM assets WHERE id = ?");
const nextPosition = database.prepare(`
SELECT COALESCE(MAX(position), -1) + 1 AS position
FROM playlist_entries
WHERE playlist_id = ?
`);
const insertEntry = database.prepare(`
INSERT INTO playlist_entries (
id, playlist_id, asset_id, enabled, duration_override_ms,
background_mode_override, background_color_override,
background_darkened_override, schedule_start_at, schedule_end_at,
position, created_at, updated_at
) VALUES (
@id, @playlistId, @assetId, @enabled, @durationOverrideMs,
@backgroundModeOverride, @backgroundColorOverride,
@backgroundDarkenedOverride, @scheduleStartAt, @scheduleEndAt,
@position, @createdAt, @updatedAt
)
`);
const updateEntry = database.prepare(`
UPDATE playlist_entries
SET enabled = @enabled,
duration_override_ms = @durationOverrideMs,
background_mode_override = @backgroundModeOverride,
background_color_override = @backgroundColorOverride,
background_darkened_override = @backgroundDarkenedOverride,
schedule_start_at = @scheduleStartAt,
schedule_end_at = @scheduleEndAt,
updated_at = @updatedAt
WHERE playlist_id = @playlistId AND id = @entryId
`);
const deleteEntry = database.prepare(
"DELETE FROM playlist_entries WHERE playlist_id = ? AND id = ?",
);
const moveEntriesOutOfRange = database.prepare(`
UPDATE playlist_entries
SET position = position + ?
WHERE playlist_id = ?
`);
const setEntryPosition = database.prepare(`
UPDATE playlist_entries
SET position = ?, updated_at = ?
WHERE playlist_id = ? AND id = ?
`);
function getPlaylistRow(playlistId) {
const row = findPlaylist.get(playlistId);
if (!row) throw playlistNotFound(playlistId);
return row;
}
function getEntryRow(playlistId, entryId) {
getPlaylistRow(playlistId);
const row = findEntry.get(playlistId, entryId);
if (!row) throw playlistEntryNotFound(playlistId, entryId);
return row;
}
function nextTimestamp(row) {
return Math.max(now(), row.updated_at + 1);
}
function notify(change) {
// The transaction has committed before this synchronous domain hook runs.
// A reconciliation failure is surfaced to the caller so playback cannot
// silently remain inconsistent with a successful playlist mutation.
onChanged(change);
}
function duplicateName(name, row = findPlaylistByName.get(name)) {
return new PlaylistError(
"PLAYLIST_NAME_CONFLICT",
"Another playlist already uses that name",
{
statusCode: 409,
details: row ? { playlist: mapPlaylist(row) } : { name },
},
);
}
function validateCreate(input) {
normalizeObject(input, "INVALID_PLAYLIST", "Playlist must be an object");
const unknown = Object.keys(input).filter((key) => key !== "name");
if (unknown.length > 0) {
throw new PlaylistError(
"INVALID_PLAYLIST",
"Playlist contains unsupported fields",
{ details: { fields: unknown } },
);
}
if (!Object.hasOwn(input, "name")) {
throw new PlaylistError("INVALID_PLAYLIST", "Playlist name is required", {
details: { field: "name" },
});
}
return normalizePlaylistName(input.name);
}
function validateEntryFields(input, { creating = false, current = null } = {}) {
normalizeObject(
input,
"INVALID_PLAYLIST_ENTRY",
"Playlist entry must be an object",
);
const allowed = creating
? new Set(["assetId", ...ENTRY_PATCH_FIELDS])
: ENTRY_PATCH_FIELDS;
const unknown = Object.keys(input).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"Playlist entry contains unsupported fields",
{ details: { fields: unknown } },
);
}
if (!creating && Object.keys(input).length === 0) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"At least one playlist entry field is required",
);
}
if (creating && (typeof input.assetId !== "string" || !input.assetId.trim())) {
throw new PlaylistError(
"INVALID_PLAYLIST_ENTRY",
"assetId is required",
{ details: { field: "assetId" } },
);
}
const value = (field, fallback) =>
Object.hasOwn(input, field) ? input[field] : fallback;
const normalized = {
assetId: creating ? input.assetId.trim() : current.asset_id,
enabled: Object.hasOwn(input, "enabled")
? normalizeBoolean(input.enabled, "enabled")
: (current?.enabled ?? 1),
durationOverrideMs: Object.hasOwn(input, "durationOverrideMs")
? normalizeNullableDuration(input.durationOverrideMs)
: (current?.duration_override_ms ?? null),
backgroundModeOverride: Object.hasOwn(input, "backgroundModeOverride")
? normalizeNullableBackgroundMode(input.backgroundModeOverride)
: (current?.background_mode_override ?? null),
backgroundColorOverride: Object.hasOwn(input, "backgroundColorOverride")
? normalizeNullableColor(input.backgroundColorOverride)
: (current?.background_color_override ?? null),
backgroundDarkenedOverride: Object.hasOwn(
input,
"backgroundDarkenedOverride",
)
? normalizeNullableDarkening(input.backgroundDarkenedOverride)
: (current?.background_darkened_override ?? null),
scheduleStartAt: normalizeNullableInstant(
value("scheduleStartAt", current?.schedule_start_at ?? null),
"scheduleStartAt",
),
scheduleEndAt: normalizeNullableInstant(
value("scheduleEndAt", current?.schedule_end_at ?? null),
"scheduleEndAt",
),
};
validateSchedule(normalized.scheduleStartAt, normalized.scheduleEndAt);
return normalized;
}
function entryValuesEqual(current, next) {
return (
current.enabled === next.enabled &&
current.duration_override_ms === next.durationOverrideMs &&
current.background_mode_override === next.backgroundModeOverride &&
current.background_color_override === next.backgroundColorOverride &&
current.background_darkened_override === next.backgroundDarkenedOverride &&
current.schedule_start_at === next.scheduleStartAt &&
current.schedule_end_at === next.scheduleEndAt
);
}
function resolvedDetail(playlistId, nowMs = now()) {
const playlist = mapPlaylist(getPlaylistRow(playlistId));
const settings = readSettings.get();
if (!settings) throw new Error("The app_settings singleton row is missing");
return {
...playlist,
evaluatedAt: nowMs,
settingsRevision: settings.revision,
entries: resolvedEntries
.all(playlistId)
.map((row) => mapResolvedEntry(row, settings, nowMs)),
};
}
const createTransaction = database.transaction((input) => {
const name = validateCreate(input);
const existing = findPlaylistByName.get(name);
if (existing) throw duplicateName(name, existing);
const timestamp = now();
const id = createId();
try {
insertPlaylist.run(id, name, timestamp, timestamp);
} catch (error) {
const conflict = findPlaylistByName.get(name);
if (conflict) throw duplicateName(name, conflict);
throw error;
}
return mapPlaylist(getPlaylistRow(id));
});
function create(input) {
const playlist = createTransaction(input);
notify({ reason: "created", playlistId: playlist.id, revision: playlist.revision });
return playlist;
}
function list() {
return listPlaylists.all().map(mapPlaylist);
}
function get(playlistId) {
return resolvedDetail(playlistId);
}
const updateTransaction = database.transaction((playlistId, input) => {
normalizeObject(input, "INVALID_PLAYLIST", "Playlist update must be an object");
const unknown = Object.keys(input).filter((key) => key !== "name");
if (unknown.length > 0) {
throw new PlaylistError(
"INVALID_PLAYLIST",
"Playlist update contains unsupported fields",
{ details: { fields: unknown } },
);
}
if (!Object.hasOwn(input, "name") || Object.keys(input).length !== 1) {
throw new PlaylistError(
"INVALID_PLAYLIST",
"Playlist update requires a name",
{ details: { field: "name" } },
);
}
const current = getPlaylistRow(playlistId);
const name = normalizePlaylistName(input.name);
if (current.name === name) {
return { changed: false, playlist: mapPlaylist(current) };
}
const conflict = findPlaylistByName.get(name);
if (conflict && conflict.id !== playlistId) throw duplicateName(name, conflict);
try {
renamePlaylist.run(name, nextTimestamp(current), playlistId);
} catch (error) {
const raced = findPlaylistByName.get(name);
if (raced && raced.id !== playlistId) throw duplicateName(name, raced);
throw error;
}
return { changed: true, playlist: mapPlaylist(getPlaylistRow(playlistId)) };
});
function update(playlistId, input) {
onBeforeChange({ reason: "before_playlist_update", playlistId });
const result = updateTransaction(playlistId, input);
if (result.changed) {
notify({
reason: "renamed",
playlistId,
revision: result.playlist.revision,
});
}
return result.playlist;
}
const removeTransaction = database.transaction((playlistId) => {
const playlist = getPlaylistRow(playlistId);
const state = readPlayback.get();
if (!state) throw new Error("The global playback_state row is missing");
if (state.active_playlist_id === playlistId) {
throw new PlaylistError(
"PLAYLIST_ACTIVE",
"The active playlist cannot be deleted",
{ statusCode: 409, details: { playlistId } },
);
}
if (state.pending_playlist_id === playlistId) {
throw new PlaylistError(
"PLAYLIST_PENDING_ACTIVATION",
"A playlist pending activation cannot be deleted",
{ statusCode: 409, details: { playlistId } },
);
}
deletePlaylist.run(playlistId);
return playlist;
});
function remove(playlistId) {
onBeforeChange({ reason: "before_playlist_delete", playlistId });
const playlist = removeTransaction(playlistId);
notify({ reason: "deleted", playlistId, revision: playlist.revision });
return { id: playlistId, deleted: true };
}
const addEntryTransaction = database.transaction((playlistId, input) => {
const playlist = getPlaylistRow(playlistId);
const fields = validateEntryFields(input, { creating: true });
const operationNow = now();
const asset = findAsset.get(fields.assetId);
if (!asset) {
throw new PlaylistError("ASSET_NOT_FOUND", "Library asset not found", {
statusCode: 404,
details: { assetId: fields.assetId },
});
}
if (asset.status !== "ready") {
throw new PlaylistError(
"ASSET_NOT_READY",
"Only ready library assets can be added to playlists",
{
statusCode: 409,
details: { assetId: asset.id, status: asset.status },
},
);
}
if (asset.expiration_at !== null && asset.expiration_at <= operationNow) {
throw new PlaylistError(
"ASSET_EXPIRED",
"Expired library assets cannot be added to playlists",
{
statusCode: 409,
details: { assetId: asset.id, expirationAt: asset.expiration_at },
},
);
}
const existing = findEntryByAsset.get(playlistId, fields.assetId);
if (existing) {
throw new PlaylistError(
"PLAYLIST_ENTRY_EXISTS",
"This asset already appears in the playlist",
{
statusCode: 409,
details: { playlistId, assetId: fields.assetId, entryId: existing.id },
},
);
}
const timestamp = Math.max(operationNow, playlist.updated_at + 1);
const id = createId();
try {
insertEntry.run({
id,
playlistId,
...fields,
position: nextPosition.get(playlistId).position,
createdAt: timestamp,
updatedAt: timestamp,
});
} catch (error) {
const raced = findEntryByAsset.get(playlistId, fields.assetId);
if (raced) {
throw new PlaylistError(
"PLAYLIST_ENTRY_EXISTS",
"This asset already appears in the playlist",
{
statusCode: 409,
details: { playlistId, assetId: fields.assetId, entryId: raced.id },
},
);
}
throw error;
}
bumpPlaylistRevision.run(timestamp, playlistId);
return {
playlist: getPlaylistRow(playlistId),
entryId: id,
detail: resolvedDetail(playlistId, now()),
};
});
function addEntry(playlistId, input) {
onBeforeChange({ reason: "before_entry_add", playlistId });
const result = addEntryTransaction(playlistId, input);
notify({
reason: "entry_added",
playlistId,
revision: result.playlist.revision,
entryId: result.entryId,
});
return result.detail.entries.find((entry) => entry.id === result.entryId);
}
const patchEntryTransaction = database.transaction((playlistId, entryId, input) => {
const playlist = getPlaylistRow(playlistId);
const current = getEntryRow(playlistId, entryId);
const fields = validateEntryFields(input, { current });
if (entryValuesEqual(current, fields)) {
return {
changed: false,
invalidBoundaryChanged: false,
playlist,
detail: resolvedDetail(playlistId, now()),
};
}
const timestamp = nextTimestamp(playlist);
updateEntry.run({
playlistId,
entryId,
...fields,
updatedAt: timestamp,
});
bumpPlaylistRevision.run(timestamp, playlistId);
return {
changed: true,
invalidBoundaryChanged:
current.schedule_end_at !== fields.scheduleEndAt,
playlist: getPlaylistRow(playlistId),
detail: resolvedDetail(playlistId, now()),
};
});
function updateEntryById(playlistId, entryId, input) {
onBeforeChange({
reason: "before_entry_update",
playlistId,
entryId,
});
const result = patchEntryTransaction(playlistId, entryId, input);
if (result.changed) {
notify({
reason: "entry_updated",
playlistId,
revision: result.playlist.revision,
entryId,
...(result.invalidBoundaryChanged
? { invalidBoundaryChanged: true }
: {}),
});
}
return result.detail.entries.find((entry) => entry.id === entryId);
}
const removeEntryTransaction = database.transaction((playlistId, entryId) => {
const playlist = getPlaylistRow(playlistId);
getEntryRow(playlistId, entryId);
const timestamp = nextTimestamp(playlist);
const result = deleteEntry.run(playlistId, entryId);
if (result.changes !== 1) throw playlistEntryNotFound(playlistId, entryId);
const remaining = listEntryIds.all(playlistId);
if (remaining.some((entry, index) => entry.position !== index)) {
moveEntriesOutOfRange.run(remaining.length + 1, playlistId);
remaining.forEach((entry, position) => {
setEntryPosition.run(position, timestamp, playlistId, entry.id);
});
}
bumpPlaylistRevision.run(timestamp, playlistId);
return { playlist: getPlaylistRow(playlistId) };
});
function removeEntryById(playlistId, entryId) {
onBeforeChange({
reason: "before_entry_delete",
playlistId,
entryId,
});
const result = removeEntryTransaction(playlistId, entryId);
notify({
reason: "entry_deleted",
playlistId,
revision: result.playlist.revision,
entryId,
});
return { id: entryId, deleted: true };
}
function validateOrder(playlistId, orderedEntryIds) {
if (!Array.isArray(orderedEntryIds)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Playlist order must be a complete array of entry IDs",
);
}
if (orderedEntryIds.some((id) => typeof id !== "string" || !id)) {
throw new PlaylistError(
"INVALID_PLAYLIST_ORDER",
"Every ordered entry ID must be a non-empty string",
);
}
const duplicates = orderedEntryIds.filter(
(id, index) => orderedEntryIds.indexOf(id) !== index,
);
if (duplicates.length > 0) {
throw new PlaylistError(
"PLAYLIST_ORDER_DUPLICATE_IDS",
"Playlist order contains duplicate entry IDs",
{ details: { entryIds: [...new Set(duplicates)] } },
);
}
const current = listEntryIds.all(playlistId);
const currentIds = new Set(current.map((entry) => entry.id));
const suppliedIds = new Set(orderedEntryIds);
const extra = orderedEntryIds.filter((id) => !currentIds.has(id));
if (extra.length > 0) {
const foreign = extra.filter((id) => {
const owner = findEntryOwner.get(id);
return owner && owner.playlist_id !== playlistId;
});
if (foreign.length > 0) {
throw new PlaylistError(
"PLAYLIST_ORDER_FOREIGN_ENTRY",
"Playlist order contains an entry from another playlist",
{ details: { entryIds: foreign } },
);
}
throw new PlaylistError(
"PLAYLIST_ORDER_UNKNOWN_ENTRY",
"Playlist order contains an unknown entry ID",
{ details: { entryIds: extra } },
);
}
const missing = current
.map((entry) => entry.id)
.filter((id) => !suppliedIds.has(id));
if (missing.length > 0) {
throw new PlaylistError(
"PLAYLIST_ORDER_INCOMPLETE",
"Playlist order is missing current entries",
{ details: { entryIds: missing } },
);
}
return current;
}
const reorderTransaction = database.transaction((playlistId, orderedEntryIds) => {
const playlist = getPlaylistRow(playlistId);
const current = validateOrder(playlistId, orderedEntryIds);
if (current.every((entry, index) => entry.id === orderedEntryIds[index])) {
return { changed: false, playlist, detail: resolvedDetail(playlistId, now()) };
}
const timestamp = nextTimestamp(playlist);
const offset = Math.max(current.length, 1);
moveEntriesOutOfRange.run(offset, playlistId);
orderedEntryIds.forEach((entryId, position) => {
setEntryPosition.run(position, timestamp, playlistId, entryId);
});
bumpPlaylistRevision.run(timestamp, playlistId);
return {
changed: true,
playlist: getPlaylistRow(playlistId),
detail: resolvedDetail(playlistId, now()),
};
});
function reorderEntries(playlistId, orderedEntryIds) {
onBeforeChange({ reason: "before_entries_reorder", playlistId });
const result = reorderTransaction(playlistId, orderedEntryIds);
if (result.changed) {
notify({ reason: "entries_reordered", playlistId, revision: result.playlist.revision });
}
return result.detail;
}
return {
addEntry,
create,
get,
list,
remove,
removeEntry: removeEntryById,
reorderEntries,
update,
updateEntry: updateEntryById,
};
}
module.exports = {
MAX_DURATION_MS,
MIN_DURATION_MS,
createPlaylistService,
normalizePlaylistName,
};
+322
View File
@@ -0,0 +1,322 @@
const http = require("node:http");
const { Server: SocketServer } = require("socket.io");
const { createApp } = require("./app");
const { openDatabase } = require("./database");
const { runMigrations } = require("./database/migrate");
const { createDisplayService } = require("./displays/service");
const { createImageProcessor } = require("./image-processing");
const { createMqttService } = require("./mqtt");
const { createNotificationService } = require("./notifications");
const {
observeAssetProcessingFailure,
observePlayback,
observeStorageHealth,
} = require("./notifications/conditions");
const { createPlaybackService } = require("./playback");
const { createStorageService } = require("./storage");
function listen(httpServer, port, host) {
return new Promise((resolve, reject) => {
function onError(error) {
httpServer.off("listening", onListening);
reject(error);
}
function onListening() {
httpServer.off("error", onError);
resolve(httpServer.address());
}
httpServer.once("error", onError);
httpServer.once("listening", onListening);
httpServer.listen(port, host);
});
}
function createRuntime({
config,
imageProcessor: suppliedImageProcessor,
logger = console,
mqttConnect,
notificationFetch,
storage: suppliedStorage,
}) {
const database = openDatabase(config.database);
let migrations;
try {
migrations = runMigrations(database);
} catch (error) {
database.close();
throw error;
}
const lifecycle = {
ready: false,
stopping: false,
};
const notifications = createNotificationService({
database,
logger,
webhookUrl: config.notifications.homeAssistantWebhookUrl,
webhookSecret: config.notifications.homeAssistantWebhookSecret,
webhookTimeoutMs: config.notifications.webhookTimeoutMs,
...(notificationFetch ? { fetchFn: notificationFetch } : {}),
});
function safely(action, message) {
try {
action();
} catch (error) {
try {
logger.warn?.(message, error);
} catch {
// Integration observation is best effort after an authoritative commit.
}
}
}
let storage;
let imageProcessor;
try {
storage =
suppliedStorage ||
createStorageService(config.storage, {
logger,
onHealthChanged(health) {
safely(
() => observeStorageHealth(notifications, health),
"Storage incident observation failed",
);
},
});
imageProcessor =
suppliedImageProcessor ||
createImageProcessor({ maxInputPixels: config.processing.maxInputPixels });
} catch (error) {
database.close();
throw error;
}
let app;
let io = null;
let playbackService;
let mqttService;
let displayService;
function emitSocketEvent(eventName, payload) {
try {
io?.emit(eventName, payload);
} catch (error) {
try {
logger.warn?.(`Socket.IO ${eventName} publish failed`, error);
} catch {
// Socket invalidation remains best effort after the authoritative commit.
}
}
}
try {
playbackService = createPlaybackService({
database,
logger,
onActivationChanged(event) {
emitSocketEvent("playlists:changed", event);
},
// Phase 3B commit ordering is unchanged: this runs after the playback
// transaction has committed, and no integration failure can undo it.
onStateChanged(snapshot) {
emitSocketEvent("playback:state", snapshot);
safely(
() => mqttService?.publishState(snapshot),
"MQTT playback state publication failed",
);
safely(
() => observePlayback(notifications, snapshot),
"Playback incident observation failed",
);
},
});
mqttService = createMqttService({
config,
logger,
playbackService,
...(mqttConnect ? { connect: mqttConnect } : {}),
});
displayService = createDisplayService({
database,
logger,
notifications,
offlineThresholdMs: config.displays.offlineThresholdMs,
onStatusChanged(event) {
emitSocketEvent("display:status", event);
},
});
app = createApp({
config,
database,
displayService,
imageProcessor,
integrations: { mqtt: mqttService, notifications },
lifecycle,
logger,
migrations,
onAssetProcessingFailed(failure) {
safely(
() => observeAssetProcessingFailure(notifications, failure),
"Asset processing incident observation failed",
);
},
onPlaylistsChanged(event) {
emitSocketEvent("playlists:changed", event);
},
playbackService,
storage,
});
} catch (error) {
storage.stop();
database.close();
throw error;
}
const httpServer = http.createServer(app);
io = new SocketServer(httpServer, {
serveClient: false,
});
io.on("connection", (socket) => {
logger.info?.(`Socket.IO client connected: ${socket.id}`);
try {
socket.emit("playback:state", playbackService.getSnapshot());
} catch (error) {
logger.warn?.("Initial playback snapshot could not be sent", error);
}
// Display telemetry is read-only reporting. It is validated, bounded, and
// can never mutate playback or playlist state.
socket.on("display:heartbeat", (payload) => {
try {
displayService.heartbeat(payload);
} catch (error) {
logger.warn?.(
`Ignoring malformed display heartbeat from ${socket.id}: ${error?.message}`,
);
}
});
socket.on("disconnect", () => {
// A closed socket is not an offline display. Only heartbeat staleness
// decides that, because browsers reconnect with a new socket ID.
logger.info?.(`Socket.IO client disconnected: ${socket.id}`);
});
});
let address = null;
let startPromise = null;
let stopPromise = null;
async function start() {
if (stopPromise) {
throw new Error("Cannot start a runtime after shutdown has begun");
}
if (startPromise) {
return startPromise;
}
startPromise = Promise.resolve()
.then(() => playbackService.initialize())
.then(() => playbackService.start())
.then(() => listen(httpServer, config.port, config.host))
.then((listeningAddress) => {
address = listeningAddress;
lifecycle.ready = true;
// None of these are readiness-critical. A broker or webhook receiver
// that is down must never keep the API from serving playback.
notifications.start();
displayService.start();
safely(() => mqttService.start(), "MQTT integration could not be started");
Promise.resolve()
.then(() => storage.start())
.catch(() => {
logger.warn?.("Object storage health checks could not be started");
});
return listeningAddress;
})
.catch((error) => {
playbackService.stop();
notifications.stop();
displayService.stop();
storage.stop();
database.close();
throw error;
});
return startPromise;
}
async function stop(reason = "shutdown") {
if (stopPromise) {
return stopPromise;
}
lifecycle.ready = false;
lifecycle.stopping = true;
playbackService.stop();
notifications.stop();
displayService.stop();
const mqttClosed = Promise.resolve()
.then(() => mqttService.stop())
.catch((error) => {
logger.warn?.("MQTT shutdown failed", error);
});
stopPromise = new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
storage.stop();
if (database.open) {
database.close();
}
resolve();
};
const timeout = setTimeout(() => {
logger.warn?.(
`Graceful shutdown timed out after ${config.shutdownTimeoutMs}ms`,
);
httpServer.closeAllConnections?.();
finish();
}, config.shutdownTimeoutMs);
timeout.unref?.();
if (!httpServer.listening) {
io.close();
finish();
return;
}
logger.info?.(`Stopping Announcements API (${reason})`);
io.close(finish);
})
// Shutdown waits for MQTT to retract its retained availability so a
// planned restart is not reported as an unexpected disappearance.
.then(() => mqttClosed);
return stopPromise;
}
return {
app,
config,
database,
displayService,
httpServer,
io,
lifecycle,
migrations,
mqttService,
notifications,
playbackService,
storage,
imageProcessor,
get address() {
return address;
},
start,
stop,
};
}
module.exports = {
createRuntime,
};
+42
View File
@@ -0,0 +1,42 @@
const express = require("express");
function createSettingsRouter(
settingsService,
{
onBeforePlaybackDomainChange = () => {},
onPlaylistsChanged = () => {},
onPlaybackDomainChanged = () => {},
} = {},
) {
const router = express.Router();
router.get("/", (request, response) => {
response.json(settingsService.get());
});
router.patch("/", (request, response, next) => {
try {
const previous = settingsService.get();
onBeforePlaybackDomainChange({ reason: "before_settings_update" });
const updated = settingsService.update(request.body);
if (updated.revision !== previous.revision) {
const change = {
reason: "settings_updated",
playlistIds: null,
settingsRevision: updated.revision,
};
onPlaybackDomainChanged(change);
onPlaylistsChanged(change);
}
response.json(updated);
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createSettingsRouter,
};
+157
View File
@@ -0,0 +1,157 @@
const SETTING_COLUMNS = Object.freeze({
defaultDurationMs: "default_duration_ms",
defaultBackgroundMode: "default_background_mode",
defaultSolidBackgroundColor: "default_solid_background_color",
defaultBackgroundDarkened: "default_background_darkened",
defaultBlurPx: "default_blur_px",
crossfadeDurationMs: "crossfade_duration_ms",
});
class SettingsValidationError extends Error {
constructor(message, details = {}) {
super(message);
this.name = "SettingsValidationError";
this.statusCode = 400;
this.code = "INVALID_SETTINGS";
this.details = details;
}
}
function mapSettings(row) {
return {
revision: row.revision,
defaultDurationMs: row.default_duration_ms,
defaultBackgroundMode: row.default_background_mode,
defaultSolidBackgroundColor: row.default_solid_background_color,
defaultBackgroundDarkened: Boolean(row.default_background_darkened),
defaultBlurPx: row.default_blur_px,
crossfadeDurationMs: row.crossfade_duration_ms,
updatedAt: row.updated_at,
};
}
function validateSafeInteger(name, value, { positive = false } = {}) {
if (!Number.isSafeInteger(value) || (positive ? value <= 0 : value < 0)) {
const qualifier = positive ? "a positive" : "a non-negative";
throw new SettingsValidationError(`${name} must be ${qualifier} integer`, {
field: name,
});
}
}
function validatePatch(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new SettingsValidationError("Settings payload must be a JSON object");
}
const keys = Object.keys(input);
const unknown = keys.filter((key) => !Object.hasOwn(SETTING_COLUMNS, key));
if (unknown.length > 0) {
throw new SettingsValidationError("Settings payload contains unknown fields", {
fields: unknown,
});
}
if (keys.length === 0) {
throw new SettingsValidationError("At least one setting is required");
}
if ("defaultDurationMs" in input) {
validateSafeInteger("defaultDurationMs", input.defaultDurationMs, {
positive: true,
});
}
if (
"defaultBackgroundMode" in input &&
!["blurred", "solid", "black"].includes(input.defaultBackgroundMode)
) {
throw new SettingsValidationError(
"defaultBackgroundMode must be blurred, solid, or black",
{ field: "defaultBackgroundMode" },
);
}
if (
"defaultSolidBackgroundColor" in input &&
(typeof input.defaultSolidBackgroundColor !== "string" ||
!/^#[0-9a-f]{6}$/i.test(input.defaultSolidBackgroundColor))
) {
throw new SettingsValidationError(
"defaultSolidBackgroundColor must be a six-digit hex color",
{ field: "defaultSolidBackgroundColor" },
);
}
if (
"defaultBackgroundDarkened" in input &&
typeof input.defaultBackgroundDarkened !== "boolean"
) {
throw new SettingsValidationError(
"defaultBackgroundDarkened must be a boolean",
{ field: "defaultBackgroundDarkened" },
);
}
if ("defaultBlurPx" in input) {
validateSafeInteger("defaultBlurPx", input.defaultBlurPx);
}
if ("crossfadeDurationMs" in input) {
validateSafeInteger("crossfadeDurationMs", input.crossfadeDurationMs);
}
return input;
}
function createSettingsService(database, { now = Date.now } = {}) {
const read = database.prepare("SELECT * FROM app_settings WHERE id = 1");
const update = database.prepare(`
UPDATE app_settings
SET revision = revision + 1,
default_duration_ms = @defaultDurationMs,
default_background_mode = @defaultBackgroundMode,
default_solid_background_color = @defaultSolidBackgroundColor,
default_background_darkened = @defaultBackgroundDarkened,
default_blur_px = @defaultBlurPx,
crossfade_duration_ms = @crossfadeDurationMs,
updated_at = @updatedAt
WHERE id = 1
`);
function get() {
const row = read.get();
if (!row) {
throw new Error("The app_settings singleton row is missing");
}
return mapSettings(row);
}
const updateTransaction = database.transaction((patch) => {
validatePatch(patch);
const current = get();
const next = { ...current, ...patch };
const changed = Object.keys(patch).some((key) => current[key] !== next[key]);
if (!changed) {
return current;
}
update.run({
defaultDurationMs: next.defaultDurationMs,
defaultBackgroundMode: next.defaultBackgroundMode,
defaultSolidBackgroundColor: next.defaultSolidBackgroundColor,
defaultBackgroundDarkened: next.defaultBackgroundDarkened ? 1 : 0,
defaultBlurPx: next.defaultBlurPx,
crossfadeDurationMs: next.crossfadeDurationMs,
updatedAt: Math.max(now(), current.updatedAt + 1),
});
return get();
});
return {
get,
update: updateTransaction,
};
}
module.exports = {
SettingsValidationError,
createSettingsService,
mapSettings,
validatePatch,
};
+425
View File
@@ -0,0 +1,425 @@
const {
CreateBucketCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} = require("@aws-sdk/client-s3");
class StorageError extends Error {
constructor(message, { code, statusCode, cause } = {}) {
super(message, { cause });
this.name = "StorageError";
this.code = code || "STORAGE_ERROR";
this.statusCode = statusCode || 500;
}
}
class StorageUnavailableError extends StorageError {
constructor(cause) {
super("Object storage is unavailable", {
code: "STORAGE_UNAVAILABLE",
statusCode: 503,
cause,
});
this.name = "StorageUnavailableError";
}
}
class StorageObjectNotFoundError extends StorageError {
constructor(key, cause) {
super("Stored object was not found", {
code: "STORAGE_OBJECT_NOT_FOUND",
statusCode: 404,
cause,
});
this.name = "StorageObjectNotFoundError";
this.key = key;
}
}
function errorStatus(error) {
return error?.$metadata?.httpStatusCode || error?.statusCode;
}
function isMissingBucket(error) {
return (
error?.name === "NoSuchBucket" ||
error?.Code === "NoSuchBucket" ||
error?.code === "NoSuchBucket" ||
errorStatus(error) === 404
);
}
function isMissingObject(error) {
if (
error?.name === "NoSuchBucket" ||
error?.Code === "NoSuchBucket" ||
error?.code === "NoSuchBucket"
) {
return false;
}
return (
error?.name === "NoSuchKey" ||
error?.name === "NotFound" ||
error?.Code === "NoSuchKey" ||
error?.code === "NoSuchKey" ||
errorStatus(error) === 404
);
}
function isBucketCreationConflict(error) {
return (
error?.name === "BucketAlreadyOwnedByYou" ||
error?.name === "BucketAlreadyExists" ||
error?.Code === "BucketAlreadyOwnedByYou" ||
error?.Code === "BucketAlreadyExists" ||
error?.code === "BucketAlreadyOwnedByYou" ||
error?.code === "BucketAlreadyExists"
);
}
function isConsumerCancellation(error) {
return (
error?.name === "AbortError" ||
error?.code === "ABORT_ERR" ||
error?.code === "ERR_STREAM_PREMATURE_CLOSE"
);
}
function assertObjectKey(key) {
if (typeof key !== "string" || key.length === 0) {
throw new TypeError("Storage object key must be a non-empty string");
}
const segments = key.split("/");
if (
key.startsWith("/") ||
key.includes("\\") ||
key.includes("\0") ||
segments.some((segment) => segment === "." || segment === "..")
) {
throw new TypeError("Storage object key is invalid");
}
}
function publicHealth(health) {
return Object.freeze({ ...health });
}
function createS3Client(config) {
const options = {
endpoint: config.endpoint,
region: config.region,
forcePathStyle: config.forcePathStyle,
maxAttempts: config.maxAttempts,
requestHandler: {
connectionTimeout: config.connectionTimeoutMs,
// Use an inactivity timeout rather than an absolute request deadline so
// a large but continuously progressing upload is not aborted.
socketTimeout: config.requestTimeoutMs,
},
// Optional AWS checksums are not uniformly implemented by S3-compatible
// providers. Source and derivative SHA-256 hashes are verified by the app.
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
};
if (config.accessKeyId && config.secretAccessKey) {
options.credentials = {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
};
}
return new S3Client(options);
}
function createStorageService(
config,
{
client,
logger = console,
now = () => new Date(),
onHealthChanged = () => {},
} = {},
) {
const s3 = client || createS3Client(config);
let ensurePromise = null;
let healthTimer = null;
let bucketReady = false;
let issuedHealthSequence = 0;
let appliedHealthSequence = 0;
let currentHealth = {
status: "degraded",
checkedAt: null,
lastSuccessAt: null,
code: "STORAGE_NOT_CHECKED",
message: "Object storage has not been checked",
};
function nextHealthSequence() {
issuedHealthSequence += 1;
return issuedHealthSequence;
}
// Only a real status transition is reported. Many requests observing the
// same outage must not become many notifications.
function announceHealth(previousStatus) {
if (previousStatus === currentHealth.status) return;
try {
onHealthChanged(publicHealth(currentHealth));
} catch (error) {
try {
logger.warn?.("Storage health observer failed", error);
} catch {
// Observability must never break a storage operation.
}
}
}
function markHealthy(sequence) {
if (sequence < appliedHealthSequence) return publicHealth(currentHealth);
appliedHealthSequence = sequence;
bucketReady = true;
const previousStatus = currentHealth.status;
const checkedAt = now().toISOString();
currentHealth = {
status: "healthy",
checkedAt,
lastSuccessAt: checkedAt,
};
announceHealth(previousStatus);
return publicHealth(currentHealth);
}
function markDegraded(sequence) {
if (sequence < appliedHealthSequence) return publicHealth(currentHealth);
appliedHealthSequence = sequence;
bucketReady = false;
const previousStatus = currentHealth.status;
currentHealth = {
status: "degraded",
checkedAt: now().toISOString(),
lastSuccessAt: currentHealth.lastSuccessAt,
code: "STORAGE_UNAVAILABLE",
message: "Object storage is unavailable",
};
announceHealth(previousStatus);
return publicHealth(currentHealth);
}
function unavailable(error, sequence) {
markDegraded(sequence);
return error instanceof StorageUnavailableError
? error
: new StorageUnavailableError(error);
}
async function ensureBucketOnce() {
const sequence = nextHealthSequence();
try {
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
markHealthy(sequence);
return { created: false };
} catch (error) {
if (!isMissingBucket(error)) {
throw unavailable(error, sequence);
}
}
try {
await s3.send(new CreateBucketCommand({ Bucket: config.bucket }));
markHealthy(sequence);
return { created: true };
} catch (error) {
if (isBucketCreationConflict(error)) {
try {
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
markHealthy(sequence);
return { created: false };
} catch (verificationError) {
throw unavailable(verificationError, sequence);
}
}
throw unavailable(error, sequence);
}
}
async function ensureBucket() {
if (!ensurePromise) {
ensurePromise = ensureBucketOnce().finally(() => {
ensurePromise = null;
});
}
return ensurePromise;
}
async function initialize() {
try {
await ensureBucket();
} catch (error) {
logger.warn?.("Object storage is unavailable during initialization");
}
return publicHealth(currentHealth);
}
async function start() {
if (!healthTimer) {
healthTimer = setInterval(() => {
ensureBucket().catch(() => {});
}, config.healthProbeIntervalMs || 30_000);
healthTimer.unref?.();
}
return initialize();
}
async function probe() {
const sequence = nextHealthSequence();
try {
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
return markHealthy(sequence);
} catch (error) {
return markDegraded(sequence);
}
}
async function putObject({ key, body, contentType, contentLength, metadata }) {
assertObjectKey(key);
if (!bucketReady) await ensureBucket();
const sequence = nextHealthSequence();
const input = {
Bucket: config.bucket,
Key: key,
Body: body,
...(contentType ? { ContentType: contentType } : {}),
...(contentLength !== undefined ? { ContentLength: contentLength } : {}),
...(metadata ? { Metadata: metadata } : {}),
};
try {
const result = await s3.send(new PutObjectCommand(input));
markHealthy(sequence);
return {
key,
etag: result.ETag || null,
versionId: result.VersionId || null,
};
} catch (error) {
throw unavailable(error, sequence);
}
}
function objectMetadata(key, result) {
return {
key,
contentType: result.ContentType || null,
contentLength:
result.ContentLength === undefined ? null : Number(result.ContentLength),
etag: result.ETag || null,
lastModified: result.LastModified || null,
metadata: result.Metadata || {},
};
}
async function getObject(key) {
assertObjectKey(key);
if (!bucketReady) await ensureBucket();
const sequence = nextHealthSequence();
try {
const result = await s3.send(
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
);
markHealthy(sequence);
result.Body?.once?.("error", (error) => {
if (!isConsumerCancellation(error)) {
markDegraded(nextHealthSequence());
}
});
return {
...objectMetadata(key, result),
body: result.Body,
};
} catch (error) {
if (isMissingObject(error)) {
markHealthy(sequence);
throw new StorageObjectNotFoundError(key, error);
}
throw unavailable(error, sequence);
}
}
async function headObject(key) {
assertObjectKey(key);
if (!bucketReady) await ensureBucket();
const sequence = nextHealthSequence();
try {
const result = await s3.send(
new HeadObjectCommand({ Bucket: config.bucket, Key: key }),
);
markHealthy(sequence);
return objectMetadata(key, result);
} catch (error) {
if (isMissingObject(error)) {
markHealthy(sequence);
return null;
}
throw unavailable(error, sequence);
}
}
async function objectExists(key) {
return (await headObject(key)) !== null;
}
async function deleteObject(key) {
assertObjectKey(key);
if (!bucketReady) await ensureBucket();
const sequence = nextHealthSequence();
try {
await s3.send(
new DeleteObjectCommand({ Bucket: config.bucket, Key: key }),
);
markHealthy(sequence);
} catch (error) {
if (isMissingObject(error)) {
markHealthy(sequence);
return;
}
throw unavailable(error, sequence);
}
}
function stop() {
if (healthTimer) {
clearInterval(healthTimer);
healthTimer = null;
}
s3.destroy?.();
}
return Object.freeze({
initialize,
start,
stop,
ensureBucket,
probe,
health: () => publicHealth(currentHealth),
putObject,
getObject,
headObject,
objectExists,
deleteObject,
destroy: stop,
});
}
module.exports = {
StorageError,
StorageObjectNotFoundError,
StorageUnavailableError,
createS3Client,
createStorageService,
};
+442
View File
@@ -0,0 +1,442 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { Readable } = require("node:stream");
const sharp = require("sharp");
const { createApp } = require("../src/app");
const { loadConfig } = require("../src/config");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const { createImageProcessor } = require("../src/image-processing");
const { StorageObjectNotFoundError } = require("../src/storage");
async function readBody(body) {
if (Buffer.isBuffer(body)) return body;
const chunks = [];
for await (const chunk of body) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks);
}
function memoryStorage() {
const objects = new Map();
return {
objects,
health() {
return {
status: "healthy",
checkedAt: "2033-05-18T03:33:20.000Z",
lastSuccessAt: "2033-05-18T03:33:20.000Z",
};
},
async putObject(input) {
objects.set(input.key, {
body: await readBody(input.body),
contentType: input.contentType,
metadata: input.metadata,
});
return { key: input.key };
},
async getObject(key) {
const object = objects.get(key);
if (!object) throw new StorageObjectNotFoundError(key);
return {
body: Readable.from([object.body]),
contentLength: object.body.length,
contentType: object.contentType,
};
},
async deleteObject(key) {
objects.delete(key);
return { key, deleted: true };
},
};
}
async function createHarness(t, { maxFileBytes = 1024 * 1024 } = {}) {
const tempDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), "announcements-api-uploads-"),
);
const config = loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
S3_ENDPOINT: "http://private-seaweedfs:8333",
S3_BUCKET: "announcements-test",
UPLOAD_TEMP_DIRECTORY: tempDirectory,
UPLOAD_MAX_FILE_BYTES: String(maxFileBytes),
UPLOAD_MAX_FILES: "5",
IMAGE_MAX_INPUT_PIXELS: "1000000",
});
const database = openDatabase({ path: ":memory:" });
const migrations = runMigrations(database);
const storage = memoryStorage();
const app = createApp({
config,
database,
imageProcessor: createImageProcessor({
maxInputPixels: config.processing.maxInputPixels,
}),
lifecycle: { ready: true },
migrations,
storage,
});
const server = app.listen(0, "127.0.0.1");
await new Promise((resolve, reject) => {
server.once("listening", resolve);
server.once("error", reject);
});
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
database.close();
fs.rmSync(tempDirectory, { recursive: true, force: true });
});
return {
baseUrl: `http://127.0.0.1:${server.address().port}`,
database,
storage,
tempDirectory,
};
}
function appendFile(form, field, buffer, filename, contentType) {
form.append(field, new Blob([buffer], { type: contentType }), filename);
}
async function jsonResponse(baseUrl, route, init) {
const response = await fetch(`${baseUrl}${route}`, init);
return { response, body: await response.json() };
}
async function eventuallyEmpty(directory) {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.readdirSync(directory).length === 0) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(fs.readdirSync(directory), []);
}
async function eventuallyContainsFile(directory) {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.readdirSync(directory).length > 0) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.fail("The upload did not create its temporary file");
}
async function sampleImages() {
const source = sharp({
create: {
width: 80,
height: 60,
channels: 4,
background: { r: 31, g: 72, b: 120, alpha: 0.5 },
},
});
return {
jpeg: await source.clone().jpeg().toBuffer(),
png: await source.clone().png().toBuffer(),
webp: await source.clone().webp().toBuffer(),
};
}
test("asset HTTP API accepts JPEG, PNG, and WebP and serves private immutable media", async (t) => {
const harness = await createHarness(t);
const images = await sampleImages();
const form = new FormData();
appendFile(form, "files", images.jpeg, "welcome.jpg", "image/jpeg");
appendFile(form, "files", images.png, "../../bulletin.png", "image/png");
appendFile(form, "files", images.webp, "events.webp", "image/webp");
const uploaded = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: form,
});
assert.equal(uploaded.response.status, 201);
assert.equal(uploaded.body.createdCount, 3);
assert.equal(uploaded.body.duplicateCount, 0);
assert.equal(uploaded.body.results.length, 3);
assert.deepEqual(
uploaded.body.results.map((result) => result.asset.original.contentType),
["image/jpeg", "image/png", "image/webp"],
);
assert.equal(uploaded.body.results[1].asset.originalFilename, "bulletin.png");
assert.doesNotMatch(JSON.stringify(uploaded.body), /objectKey|object_key|private-seaweedfs/);
assert.equal(harness.storage.objects.size, 9);
await eventuallyEmpty(harness.tempDirectory);
const asset = uploaded.body.results[0].asset;
const listed = await jsonResponse(harness.baseUrl, "/api/assets");
assert.equal(listed.response.status, 200);
assert.equal(listed.body.assets.length, 3);
assert.deepEqual(
(await jsonResponse(harness.baseUrl, `/api/assets/${asset.id}`)).body,
asset,
);
for (const media of [asset.display, asset.thumbnail]) {
const response = await fetch(`${harness.baseUrl}${media.url}`);
const body = Buffer.from(await response.arrayBuffer());
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "image/webp");
assert.equal(
response.headers.get("cache-control"),
"public, max-age=31536000, immutable",
);
assert.equal(response.headers.get("content-length"), String(body.length));
assert.equal(response.headers.get("etag"), `"${media.sha256}"`);
assert.equal(crypto.createHash("sha256").update(body).digest("hex"), media.sha256);
}
const original = await fetch(`${harness.baseUrl}${asset.original.url}`);
assert.equal(original.status, 200);
assert.equal(original.headers.get("content-type"), "image/jpeg");
assert.equal(original.headers.get("cache-control"), "private, no-store");
assert.match(original.headers.get("content-disposition"), /^attachment;/);
assert.deepEqual(Buffer.from(await original.arrayBuffer()), images.jpeg);
const wrongVersion = await jsonResponse(
harness.baseUrl,
`/media/assets/${asset.id}/display/${"a".repeat(64)}.webp`,
);
assert.equal(wrongVersion.response.status, 404);
assert.equal(wrongVersion.body.error.code, "MEDIA_NOT_FOUND");
const arbitraryKey = await jsonResponse(
harness.baseUrl,
`/media/assets/${asset.id}/display/${encodeURIComponent("../../original/key")}.webp`,
);
assert.equal(arbitraryKey.response.status, 404);
assert.equal(arbitraryKey.body.error.code, "MEDIA_NOT_FOUND");
const displayKey = [...harness.storage.objects.keys()].find(
(key) => key.includes(`assets/${asset.id}/display/`),
);
harness.storage.objects.delete(displayKey);
const missingStoredBytes = await jsonResponse(
harness.baseUrl,
asset.display.url,
);
assert.equal(missingStoredBytes.response.status, 503);
assert.equal(missingStoredBytes.body.error.code, "STORAGE_INCONSISTENCY");
assert.equal(
(await jsonResponse(harness.baseUrl, "/health/live")).response.status,
200,
);
});
test("asset HTTP API reports duplicates, name conflicts, and persists editable metadata", async (t) => {
const harness = await createHarness(t);
const { png, jpeg } = await sampleImages();
const firstForm = new FormData();
appendFile(firstForm, "file", png, "weekly.png", "application/octet-stream");
firstForm.append("display_name", "Weekly Announcement");
const first = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: firstForm,
});
assert.equal(first.response.status, 201);
assert.equal(first.body.created, true);
const assetId = first.body.asset.id;
const duplicateForm = new FormData();
appendFile(duplicateForm, "file", png, "unrelated-name.png", "image/png");
duplicateForm.append("display_name", "Ignored duplicate proposal");
const duplicate = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: duplicateForm,
});
assert.equal(duplicate.response.status, 200);
assert.equal(duplicate.body.created, false);
assert.equal(duplicate.body.duplicate, true);
assert.equal(duplicate.body.asset.id, assetId);
assert.equal(harness.storage.objects.size, 3);
const conflictForm = new FormData();
appendFile(conflictForm, "file", jpeg, "different.jpg", "image/jpeg");
conflictForm.append("displayName", "weekly announcement");
const conflict = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: conflictForm,
});
assert.equal(conflict.response.status, 409);
assert.equal(conflict.body.error.code, "ASSET_NAME_CONFLICT");
const expiresAt = Date.parse("2034-01-02T03:04:05.000Z");
const edited = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
display_name: "Renamed announcement",
expiration_at: expiresAt,
}),
});
assert.equal(edited.response.status, 200);
assert.equal(edited.body.displayName, "Renamed announcement");
assert.equal(edited.body.expirationAt, expiresAt);
const permanent = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ expiration_at: null }),
});
assert.equal(permanent.body.expirationAt, null);
assert.equal(harness.storage.objects.size, 3);
await eventuallyEmpty(harness.tempDirectory);
const timestamp = Date.now();
harness.database
.prepare(
"INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.run("api-playlist", "API playlist", timestamp, timestamp);
harness.database
.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, position, created_at, updated_at)
VALUES (?, ?, ?, 0, ?, ?)`,
)
.run("api-entry", "api-playlist", assetId, timestamp, timestamp);
const references = await jsonResponse(
harness.baseUrl,
`/api/assets/${assetId}/references`,
);
assert.equal(references.response.status, 200);
assert.equal(references.body.canDelete, false);
assert.equal(references.body.playlists[0].playlistEntryId, "api-entry");
const blockedDelete = await jsonResponse(
harness.baseUrl,
`/api/assets/${assetId}`,
{ method: "DELETE" },
);
assert.equal(blockedDelete.response.status, 409);
assert.equal(blockedDelete.body.error.code, "ASSET_REFERENCED");
assert.equal(harness.storage.objects.size, 3);
harness.database
.prepare("DELETE FROM playlist_entries WHERE id = ?")
.run("api-entry");
const removed = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
method: "DELETE",
});
assert.equal(removed.response.status, 200);
assert.deepEqual(removed.body, { id: assetId, deleted: true });
assert.equal(harness.storage.objects.size, 0);
assert.equal(
(await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`)).response.status,
404,
);
});
test("upload validation returns structured errors and always cleans temporary files", async (t) => {
const harness = await createHarness(t, { maxFileBytes: 1024 });
const malformed = new FormData();
appendFile(malformed, "file", Buffer.from("not an image"), "broken.png", "image/png");
const malformedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: malformed,
});
assert.equal(malformedResult.response.status, 422);
assert.equal(malformedResult.body.error.code, "MALFORMED_IMAGE");
await eventuallyEmpty(harness.tempDirectory);
const unsupportedBuffer = await sharp({
create: {
width: 20,
height: 20,
channels: 3,
background: "red",
},
})
.gif()
.toBuffer();
const unsupported = new FormData();
appendFile(unsupported, "file", unsupportedBuffer, "still.gif", "image/png");
const unsupportedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: unsupported,
});
assert.equal(unsupportedResult.response.status, 415);
assert.equal(unsupportedResult.body.error.code, "UNSUPPORTED_IMAGE_FORMAT");
await eventuallyEmpty(harness.tempDirectory);
const oversized = new FormData();
appendFile(
oversized,
"file",
crypto.randomBytes(1025),
"too-large.png",
"image/png",
);
const oversizedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: oversized,
});
assert.equal(oversizedResult.response.status, 413);
assert.equal(oversizedResult.body.error.code, "UPLOAD_FILE_TOO_LARGE");
assert.deepEqual(oversizedResult.body.error.details, { maxFileBytes: 1024 });
await eventuallyEmpty(harness.tempDirectory);
const tooMany = new FormData();
for (let index = 0; index < 6; index += 1) {
appendFile(tooMany, "files", Buffer.from("x"), `${index}.png`, "image/png");
}
const tooManyResult = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: tooMany,
});
assert.equal(tooManyResult.response.status, 413);
assert.equal(tooManyResult.body.error.code, "UPLOAD_FILE_COUNT_EXCEEDED");
assert.deepEqual(tooManyResult.body.error.details, { maxFiles: 5 });
await eventuallyEmpty(harness.tempDirectory);
const absent = await jsonResponse(harness.baseUrl, "/api/assets", {
method: "POST",
body: new FormData(),
});
assert.equal(absent.response.status, 400);
assert.equal(absent.body.error.code, "UPLOAD_FILE_REQUIRED");
assert.equal(harness.storage.objects.size, 0);
assert.equal(
harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count,
0,
);
});
test("an aborted multipart request removes its in-flight temporary file", async (t) => {
const harness = await createHarness(t);
const boundary = `announcements-${crypto.randomUUID()}`;
const preamble = Buffer.from(
`--${boundary}\r\n` +
'Content-Disposition: form-data; name="file"; filename="aborted.png"\r\n' +
"Content-Type: image/png\r\n\r\n",
);
const declaredLength = preamble.length + 512 * 1024;
const request = http.request(`${harness.baseUrl}/api/assets`, {
method: "POST",
headers: {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(declaredLength),
},
});
request.on("error", () => {});
request.write(preamble);
request.write(Buffer.alloc(64 * 1024, 1));
await eventuallyContainsFile(harness.tempDirectory);
const closed = new Promise((resolve) => request.once("close", resolve));
request.destroy();
await closed;
await eventuallyEmpty(harness.tempDirectory);
assert.equal(harness.storage.objects.size, 0);
assert.equal(
harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count,
0,
);
});
+35
View File
@@ -0,0 +1,35 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
mapMulterError,
safeOriginalFilename,
} = require("../src/assets/multipart");
test("original filename metadata keeps a safe leaf without path traversal", () => {
assert.equal(safeOriginalFilename("../../Sunday/welcome.png"), "welcome.png");
assert.equal(safeOriginalFilename("..\\..\\Sunday\\welcome.png"), "welcome.png");
assert.equal(safeOriginalFilename(" welcome sign.png "), " welcome sign.png ");
});
test("original filename metadata removes controls and remains bounded", () => {
assert.equal(safeOriginalFilename("wel\u0000come\n.png"), "welcome.png");
assert.equal([...safeOriginalFilename(`${"a".repeat(300)}.png`)].length, 255);
assert.throws(
() => safeOriginalFilename(" \u0000\n "),
(error) => error.code === "INVALID_ORIGINAL_FILENAME",
);
});
test("temporary-storage failures are server errors, not invalid client uploads", () => {
const storageFailure = mapMulterError(
Object.assign(new Error("disk full at a private path"), { code: "ENOSPC" }),
{},
);
assert.equal(storageFailure.code, "UPLOAD_TEMP_STORAGE_UNAVAILABLE");
assert.equal(storageFailure.statusCode, 503);
assert.doesNotMatch(storageFailure.message, /private path/);
const malformed = mapMulterError(new Error("Unexpected end of form"), {});
assert.equal(malformed.code, "INVALID_MULTIPART_UPLOAD");
assert.equal(malformed.statusCode, 400);
});
+608
View File
@@ -0,0 +1,608 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Readable } = require("node:stream");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const { createAssetService } = require("../src/assets/service");
const {
StorageObjectNotFoundError,
StorageUnavailableError,
} = require("../src/storage");
async function bufferBody(body) {
if (Buffer.isBuffer(body)) return body;
if (body instanceof Uint8Array) return Buffer.from(body);
const chunks = [];
for await (const chunk of body) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks);
}
function createMemoryStorage() {
const objects = new Map();
const calls = [];
let failNextPut = null;
let successfulPutsBeforeFailure = 0;
let failNextDelete = null;
let putDelayMs = 0;
return {
objects,
calls,
setPutFailure(error, putsBeforeFailure = 0) {
failNextPut = error;
successfulPutsBeforeFailure = putsBeforeFailure;
},
setDeleteFailure(error) {
failNextDelete = error;
},
setPutDelay(milliseconds) {
putDelayMs = milliseconds;
},
async putObject(input) {
calls.push({ operation: "put", ...input, body: undefined });
if (putDelayMs) {
await new Promise((resolve) => setTimeout(resolve, putDelayMs));
}
if (failNextPut) {
if (successfulPutsBeforeFailure === 0) {
const error = failNextPut;
failNextPut = null;
throw error;
}
successfulPutsBeforeFailure -= 1;
}
objects.set(input.key, {
body: await bufferBody(input.body),
contentType: input.contentType,
metadata: input.metadata,
});
return { key: input.key };
},
async getObject(key) {
calls.push({ operation: "get", key });
const object = objects.get(key);
if (!object) throw new StorageObjectNotFoundError(key);
return {
body: Readable.from(object.body),
contentLength: object.body.length,
contentType: object.contentType,
};
},
async deleteObject(key) {
calls.push({ operation: "delete", key });
if (failNextDelete) {
const error = failNextDelete;
failNextDelete = null;
throw error;
}
objects.delete(key);
return { key, deleted: true };
},
};
}
function createFakeImageProcessor({ reject } = {}) {
const calls = [];
return {
calls,
async process(filename) {
calls.push(filename);
if (reject) throw reject;
const original = await fs.promises.readFile(filename);
const sourceSha256 = crypto.createHash("sha256").update(original).digest("hex");
const displayBuffer = Buffer.from(`display:${sourceSha256}`);
const thumbnailBuffer = Buffer.from(`thumbnail:${sourceSha256}`);
return {
original: {
sha256: sourceSha256,
contentType: "image/png",
byteSize: original.length,
width: 1200,
height: 800,
},
display: {
buffer: displayBuffer,
sha256: crypto.createHash("sha256").update(displayBuffer).digest("hex"),
contentType: "image/webp",
byteSize: displayBuffer.length,
width: 1200,
height: 800,
},
thumbnail: {
buffer: thumbnailBuffer,
sha256: crypto.createHash("sha256").update(thumbnailBuffer).digest("hex"),
contentType: "image/webp",
byteSize: thumbnailBuffer.length,
width: 405,
height: 270,
},
};
},
};
}
function createHarness(options = {}) {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-assets-"));
const storage = options.storage || createMemoryStorage();
const imageProcessor =
options.imageProcessor || createFakeImageProcessor(options.processorOptions);
let idCounter = 0;
let clock = 2_000_000_000_000;
const service = createAssetService({
database,
storage,
imageProcessor,
now: () => ++clock,
createId:
options.createId ||
(() => `00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`),
concurrentUploadWaitMs: options.concurrentUploadWaitMs ?? 2_000,
});
function uploadFile(originalFilename, contents) {
const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
const filename = path.join(directory, crypto.randomUUID());
fs.writeFileSync(filename, buffer);
return { path: filename, byteSize: buffer.length, originalFilename };
}
return {
database,
directory,
imageProcessor,
service,
storage,
uploadFile,
close() {
database.close();
fs.rmSync(directory, { recursive: true, force: true });
},
};
}
function rawAsset(database, assetId) {
return database.prepare("SELECT * FROM assets WHERE id = ?").get(assetId);
}
test("upload stores exact bytes under content-addressed keys without leaking keys", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const original = Buffer.from("exact uploaded bytes");
const [result] = await harness.service.upload([
harness.uploadFile(" Sunday Welcome.PNG ", original),
]);
assert.equal(result.created, true);
assert.equal(result.duplicate, false);
assert.match(result.asset.id, /^[0-9a-f-]{36}$/);
assert.equal(result.asset.displayName, "Sunday Welcome");
assert.equal(result.asset.originalFilename, " Sunday Welcome.PNG ");
assert.equal(result.asset.expirationAt, null);
assert.equal(result.asset.status, "ready");
assert.equal(result.asset.original.byteSize, original.length);
assert.equal(result.asset.display.contentType, "image/webp");
assert.equal(result.asset.thumbnail.width, 405);
assert.match(result.asset.display.url, /^\/media\/assets\//);
assert.doesNotMatch(
JSON.stringify(result.asset),
/object_key|originalObjectKey|displayObjectKey|thumbnailObjectKey|seaweed/i,
);
const row = rawAsset(harness.database, result.asset.id);
const expectedSourceHash = crypto.createHash("sha256").update(original).digest("hex");
assert.equal(row.source_sha256, expectedSourceHash);
assert.equal(row.original_object_key, `assets/${row.id}/original/${expectedSourceHash}`);
assert.equal(
row.display_object_key,
`assets/${row.id}/display/${row.display_sha256}.webp`,
);
assert.equal(
row.thumbnail_object_key,
`assets/${row.id}/thumbnail/${row.thumbnail_sha256}.webp`,
);
assert.deepEqual(harness.storage.objects.get(row.original_object_key).body, original);
assert.equal(harness.storage.objects.size, 3);
assert.deepEqual(harness.service.list(), [result.asset]);
assert.deepEqual(harness.service.get(result.asset.id), result.asset);
});
test("exact duplicate detection takes precedence over filename and display name", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const contents = Buffer.from("same image bytes");
const [created] = await harness.service.upload([
harness.uploadFile("first.png", contents),
]);
const [sameFilename] = await harness.service.upload([
harness.uploadFile("first.png", contents),
]);
const [differentFilename] = await harness.service.upload(
[harness.uploadFile("completely different name.webp", contents)],
{ displayName: "A conflicting requested name is ignored for an exact duplicate" },
);
for (const duplicate of [sameFilename, differentFilename]) {
assert.equal(duplicate.created, false);
assert.equal(duplicate.duplicate, true);
assert.equal(duplicate.asset.id, created.asset.id);
}
assert.equal(harness.service.list().length, 1);
assert.equal(harness.storage.calls.filter((call) => call.operation === "put").length, 3);
assert.equal(harness.imageProcessor.calls.length, 1);
});
test("different content with the same proposed name is a conflict, not a duplicate", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
await harness.service.upload([harness.uploadFile("bulletin.png", "image one")]);
await assert.rejects(
harness.service.upload([harness.uploadFile("bulletin.jpg", "image two")]),
(error) => {
assert.equal(error.statusCode, 409);
assert.equal(error.code, "ASSET_NAME_CONFLICT");
assert.equal(error.details.asset.displayName, "bulletin");
return true;
},
);
assert.equal(harness.service.list().length, 1);
assert.equal(harness.imageProcessor.calls.length, 1);
const [created] = await harness.service.upload(
[harness.uploadFile("bulletin.jpg", "image two")],
{ displayName: "Second bulletin" },
);
assert.equal(created.created, true);
assert.notEqual(created.asset.sourceSha256, harness.service.list()[1].sourceSha256);
assert.equal(harness.service.list().length, 2);
});
test("multiple-file upload preserves order and deduplicates repeated bytes in the batch", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const results = await harness.service.upload([
harness.uploadFile("alpha.png", "alpha bytes"),
harness.uploadFile("beta.png", "beta bytes"),
harness.uploadFile("alpha-copy.png", "alpha bytes"),
]);
assert.deepEqual(
results.map(({ created, duplicate }) => ({ created, duplicate })),
[
{ created: true, duplicate: false },
{ created: true, duplicate: false },
{ created: false, duplicate: true },
],
);
assert.equal(results[0].asset.id, results[2].asset.id);
assert.equal(harness.service.list().length, 2);
assert.equal(harness.imageProcessor.calls.length, 2);
assert.equal(harness.storage.objects.size, 6);
});
test("all new images are decoded before the batch mutates SQLite or storage", async (t) => {
const expected = Object.assign(new Error("bad second image"), {
code: "MALFORMED_IMAGE",
statusCode: 422,
});
let processCount = 0;
const regularProcessor = createFakeImageProcessor();
const harness = createHarness({
imageProcessor: {
async process(filename) {
processCount += 1;
if (processCount === 2) throw expected;
return regularProcessor.process(filename);
},
},
});
t.after(() => harness.close());
await assert.rejects(
harness.service.upload([
harness.uploadFile("valid.png", "valid"),
harness.uploadFile("broken.png", "broken"),
]),
expected,
);
assert.equal(harness.service.list().length, 0);
assert.equal(harness.storage.objects.size, 0);
assert.equal(harness.storage.calls.length, 0);
});
test("concurrent identical uploads converge on one ready asset", async (t) => {
const storage = createMemoryStorage();
storage.setPutDelay(30);
const harness = createHarness({ storage });
t.after(() => harness.close());
const bytes = Buffer.from("racing identical upload");
const [[left], [right]] = await Promise.all([
harness.service.upload([harness.uploadFile("race.png", bytes)]),
harness.service.upload([harness.uploadFile("other-name.png", bytes)]),
]);
assert.equal(harness.service.list().length, 1);
assert.equal(left.asset.id, right.asset.id);
assert.deepEqual([left.created, right.created].sort(), [false, true]);
assert.deepEqual([left.duplicate, right.duplicate].sort(), [false, true]);
assert.equal(storage.calls.filter((call) => call.operation === "put").length, 3);
});
test("a partial storage failure leaves a diagnosable failed row and no ready URLs", async (t) => {
const storage = createMemoryStorage();
storage.setPutFailure(new StorageUnavailableError(new Error("offline")), 1);
const harness = createHarness({ storage });
t.after(() => harness.close());
await assert.rejects(
harness.service.upload([harness.uploadFile("failure.png", "failure bytes")]),
(error) => {
assert.equal(error.statusCode, 503);
assert.equal(error.details.retryable, true);
assert.match(error.details.assetId, /^[0-9a-f-]{36}$/);
return true;
},
);
const [failed] = harness.service.list();
assert.equal(failed.status, "failed");
assert.match(failed.processingError, /^STORAGE_UNAVAILABLE:/);
assert.equal(failed.display.url, null);
assert.equal(failed.thumbnail.url, null);
assert.equal(failed.original.url, null);
assert.equal(storage.objects.size, 0);
const row = rawAsset(harness.database, failed.id);
assert.match(
row.original_object_key,
new RegExp(`^assets/${failed.id}/original/[0-9a-f]{64}$`),
);
assert.equal(
row.display_object_key,
`assets/${failed.id}/display/${row.display_sha256}.webp`,
);
assert.equal(
row.thumbnail_object_key,
`assets/${failed.id}/thumbnail/${row.thumbnail_sha256}.webp`,
);
assert.equal(storage.calls.filter((call) => call.operation === "put").length, 2);
assert.deepEqual(
new Set(
storage.calls
.filter((call) => call.operation === "delete")
.map((call) => call.key),
),
new Set([
row.original_object_key,
row.display_object_key,
row.thumbnail_object_key,
]),
);
await assert.rejects(
harness.service.upload([harness.uploadFile("retry.png", "failure bytes")]),
(error) => error.code === "ASSET_PROCESSING_FAILED" && error.statusCode === 409,
);
assert.deepEqual(await harness.service.remove(failed.id), {
id: failed.id,
deleted: true,
});
assert.equal(harness.service.list().length, 0);
});
test("rename and expiration edits change only approved SQLite metadata", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const [upload] = await harness.service.upload([
harness.uploadFile("editable.png", "editable bytes"),
]);
const before = rawAsset(harness.database, upload.asset.id);
const expiring = harness.service.update(upload.asset.id, {
display_name: " New display name ",
expiration_at: "2035-05-06T07:08:09.000Z",
});
assert.equal(expiring.displayName, "New display name");
assert.equal(expiring.expirationAt, Date.parse("2035-05-06T07:08:09.000Z"));
const permanent = harness.service.update(upload.asset.id, { expirationAt: null });
assert.equal(permanent.expirationAt, null);
const after = rawAsset(harness.database, upload.asset.id);
assert.equal(after.original_object_key, before.original_object_key);
assert.equal(after.display_object_key, before.display_object_key);
assert.equal(after.thumbnail_object_key, before.thumbnail_object_key);
assert.equal(harness.storage.calls.filter((call) => call.operation === "put").length, 3);
await assert.rejects(
async () => harness.service.update(upload.asset.id, { status: "failed" }),
(error) => error.code === "INVALID_ASSET_UPDATE",
);
assert.throws(
() => harness.service.update(upload.asset.id, { expirationAt: -1 }),
(error) => error.code === "INVALID_ASSET_EXPIRATION",
);
});
test("references describe playlist, normal, and static use and block deletion", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const [upload] = await harness.service.upload([
harness.uploadFile("referenced.png", "referenced bytes"),
]);
const id = upload.asset.id;
const now = 2_100_000_000_000;
harness.database
.prepare(
"INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.run("playlist-1", "Sunday", now, now);
harness.database
.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, enabled, position, created_at, updated_at)
VALUES (?, ?, ?, 1, 0, ?, ?)`,
)
.run("entry-1", "playlist-1", id, now, now);
harness.database
.prepare(
`UPDATE playback_state
SET active_playlist_id = ?, current_entry_id = ?, static_asset_id = ?, updated_at = ?
WHERE output_id = 'global'`,
)
.run("playlist-1", "entry-1", id, now);
const references = harness.service.references(id);
assert.equal(references.canDelete, false);
assert.deepEqual(references.blockerCodes, [
"PLAYLIST_REFERENCES",
"CURRENT_PLAYBACK_TARGET",
"CURRENT_STATIC_TARGET",
]);
assert.deepEqual(references.playlists[0], {
playlistId: "playlist-1",
playlistName: "Sunday",
playlistEntryId: "entry-1",
enabled: true,
position: 0,
isActivePlaylist: true,
isCurrentNormalTarget: true,
});
assert.equal(references.playback[0].isCurrentNormalTarget, true);
assert.equal(references.playback[0].isCurrentStaticTarget, true);
const callsBeforeDelete = harness.storage.calls.length;
await assert.rejects(
harness.service.remove(id),
(error) => {
assert.equal(error.code, "ASSET_REFERENCED");
assert.equal(error.statusCode, 409);
assert.deepEqual(error.details.blockerCodes, [
"PLAYLIST_REFERENCES",
"CURRENT_PLAYBACK_TARGET",
"CURRENT_STATIC_TARGET",
]);
return true;
},
);
assert.equal(harness.storage.calls.length, callsBeforeDelete);
assert.equal(rawAsset(harness.database, id).status, "ready");
harness.database
.prepare(
`UPDATE playback_state
SET active_playlist_id = NULL, current_entry_id = NULL, static_asset_id = NULL
WHERE output_id = 'global'`,
)
.run();
harness.database.prepare("DELETE FROM playlist_entries WHERE id = ?").run("entry-1");
assert.equal(harness.service.references(id).canDelete, true);
await harness.service.remove(id);
assert.equal(harness.storage.objects.size, 0);
assert.equal(rawAsset(harness.database, id), undefined);
});
test("failed storage deletion retains a retryable deleting row", async (t) => {
const storage = createMemoryStorage();
const harness = createHarness({ storage });
t.after(() => harness.close());
const [upload] = await harness.service.upload([
harness.uploadFile("delete-me.png", "delete me"),
]);
storage.setDeleteFailure(new StorageUnavailableError(new Error("offline")));
await assert.rejects(harness.service.remove(upload.asset.id), (error) => {
assert.equal(error.statusCode, 503);
assert.equal(error.details.assetId, upload.asset.id);
assert.equal(error.details.retryable, true);
return true;
});
assert.equal(rawAsset(harness.database, upload.asset.id).status, "deleting");
assert.throws(
() => harness.service.update(upload.asset.id, { displayName: "Cannot edit" }),
(error) => error.code === "ASSET_DELETING",
);
await harness.service.remove(upload.asset.id);
assert.equal(rawAsset(harness.database, upload.asset.id), undefined);
assert.equal(storage.objects.size, 0);
});
test("processing assets cannot be deleted and storage is not touched", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const timestamp = Date.now();
harness.database
.prepare(
`INSERT INTO assets
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'processing', ?, ?)`,
)
.run(
"00000000-0000-4000-8000-999999999999",
"f".repeat(64),
"Still processing",
"processing.png",
timestamp,
timestamp,
);
assert.equal(
harness.service.references("00000000-0000-4000-8000-999999999999")
.canDelete,
false,
);
await assert.rejects(
harness.service.remove("00000000-0000-4000-8000-999999999999"),
(error) => error.code === "ASSET_PROCESSING" && error.statusCode === 409,
);
assert.equal(harness.storage.calls.length, 0);
assert.equal(
rawAsset(harness.database, "00000000-0000-4000-8000-999999999999").status,
"processing",
);
});
test("media descriptors only allow the recorded version and map missing bytes to 503", async (t) => {
const harness = createHarness();
t.after(() => harness.close());
const [upload] = await harness.service.upload([
harness.uploadFile("media.png", "media bytes"),
]);
const { asset } = upload;
const display = harness.service.mediaDescriptor(
asset.id,
"display",
asset.display.sha256,
);
assert.equal(display.contentType, "image/webp");
assert.equal(display.contentLength, asset.display.byteSize);
const stored = await harness.service.readStoredObject(display);
assert.deepEqual(await bufferBody(stored.body), harness.storage.objects.get(display.key).body);
for (const requestedHash of ["a".repeat(64), "../original/key", "not-a-hash"]) {
assert.throws(
() => harness.service.mediaDescriptor(asset.id, "display", requestedHash),
(error) => error.code === "MEDIA_NOT_FOUND" && error.statusCode === 404,
);
}
harness.storage.objects.delete(display.key);
await assert.rejects(harness.service.readStoredObject(display), (error) => {
assert.equal(error.code, "STORAGE_INCONSISTENCY");
assert.equal(error.statusCode, 503);
assert.deepEqual(error.details, { assetId: asset.id, retryable: true });
return true;
});
});
+128
View File
@@ -0,0 +1,128 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { ConfigError, loadConfig, redactedConfig } = require("../src/config");
function validEnvironment(overrides = {}) {
return {
NODE_ENV: "production",
HOST: "127.0.0.1",
PORT: "7532",
DATABASE_PATH: "/data/db/announcements.sqlite",
APP_TIMEZONE: "America/Chicago",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_REGION: "us-east-1",
S3_BUCKET: "announcements",
S3_ACCESS_KEY_ID: "test-access-key",
S3_SECRET_ACCESS_KEY: "test-secret-key",
S3_FORCE_PATH_STYLE: "true",
MQTT_URL: "mqtt://broker:1883",
MQTT_USERNAME: "mqtt-user",
MQTT_PASSWORD: "mqtt-password",
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/example",
HA_WEBHOOK_SECRET: "webhook-secret",
...overrides,
};
}
test("loadConfig validates and normalizes the supported environment", () => {
const config = loadConfig(validEnvironment());
assert.equal(config.environment, "production");
assert.equal(config.port, 7532);
assert.equal(config.timeZone, "America/Chicago");
assert.equal(config.storage.endpoint, "http://seaweedfs:8333");
assert.equal(config.storage.forcePathStyle, true);
assert.equal(config.storage.healthProbeIntervalMs, 30_000);
assert.equal(config.storage.connectionTimeoutMs, 2_000);
assert.equal(config.storage.requestTimeoutMs, 5_000);
assert.equal(config.storage.maxAttempts, 2);
assert.equal(config.processing.tempDirectory, "/tmp/announcements-uploads");
assert.equal(config.processing.maxFileBytes, 25 * 1024 * 1024);
assert.equal(config.processing.maxFiles, 20);
assert.equal(config.processing.maxInputPixels, 40_000_000);
});
test("production requires private S3 credentials", () => {
assert.throws(
() => loadConfig(validEnvironment({ S3_ACCESS_KEY_ID: "" })),
(error) =>
error instanceof ConfigError && error.field === "S3_ACCESS_KEY_ID",
);
const development = loadConfig(
validEnvironment({
NODE_ENV: "development",
S3_ACCESS_KEY_ID: "",
S3_SECRET_ACCESS_KEY: "",
}),
);
assert.equal(development.storage.accessKeyId, null);
assert.equal(development.storage.secretAccessKey, null);
});
test("loadConfig rejects invalid typed values", () => {
assert.throws(
() => loadConfig(validEnvironment({ PORT: "7532.5" })),
/PORT must be an integer/,
);
assert.throws(
() => loadConfig(validEnvironment({ APP_TIMEZONE: "Church/Local" })),
/valid IANA time zone/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_FORCE_PATH_STYLE: "sometimes" })),
/must be a boolean/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_HEALTH_PROBE_INTERVAL_MS: "999" })),
/S3_HEALTH_PROBE_INTERVAL_MS must be at least 1000/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_REQUEST_TIMEOUT_MS: "999" })),
/S3_REQUEST_TIMEOUT_MS must be at least 1000/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_CONNECT_TIMEOUT_MS: "249" })),
/S3_CONNECT_TIMEOUT_MS must be at least 250/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_MAX_ATTEMPTS: "6" })),
/S3_MAX_ATTEMPTS must be at most 5/,
);
assert.throws(
() => loadConfig(validEnvironment({ DATABASE_PATH: "relative.sqlite" })),
/must be an absolute path/,
);
assert.throws(
() => loadConfig(validEnvironment({ UPLOAD_TEMP_DIRECTORY: "relative" })),
/UPLOAD_TEMP_DIRECTORY must be an absolute path/,
);
assert.throws(
() => loadConfig(validEnvironment({ UPLOAD_MAX_FILE_BYTES: "0" })),
/UPLOAD_MAX_FILE_BYTES must be at least 1/,
);
assert.throws(
() => loadConfig(validEnvironment({ UPLOAD_MAX_FILES: "101" })),
/UPLOAD_MAX_FILES must be at most 100/,
);
assert.throws(
() => loadConfig(validEnvironment({ IMAGE_MAX_INPUT_PIXELS: "no-limit" })),
/IMAGE_MAX_INPUT_PIXELS must be an integer/,
);
});
test("redactedConfig never returns credentials, passwords, paths, or webhook URLs", () => {
const summary = redactedConfig(loadConfig(validEnvironment()));
const serialized = JSON.stringify(summary);
assert.equal(summary.storage.configured, true);
assert.equal(summary.mqtt.configured, true);
assert.equal(summary.notifications.configured, true);
assert.doesNotMatch(serialized, /test-access-key/);
assert.doesNotMatch(serialized, /test-secret-key/);
assert.doesNotMatch(serialized, /mqtt-password/);
assert.doesNotMatch(serialized, /webhook-secret/);
assert.doesNotMatch(serialized, /announcements\.sqlite/);
assert.doesNotMatch(serialized, /seaweedfs/);
assert.doesNotMatch(serialized, /home-assistant/);
});
+275
View File
@@ -0,0 +1,275 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { openDatabase } = require("../src/database");
const { migrationStatus, runMigrations } = require("../src/database/migrate");
function migratedDatabase() {
const database = openDatabase({ path: ":memory:" });
const migrations = runMigrations(database);
return { database, migrations };
}
function columnNames(database, table) {
return database.pragma(`table_info(${table})`).map((column) => column.name);
}
test("initial migration creates every approved Phase 0 table and seed row", (t) => {
const { database, migrations } = migratedDatabase();
t.after(() => database.close());
const tables = database
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`,
)
.all()
.map((row) => row.name);
assert.deepEqual(tables, [
"app_settings",
"asset_import_sources",
"assets",
"display_clients",
"notification_incidents",
"notification_outbox",
"playback_state",
"playlist_entries",
"playlists",
"schema_migrations",
]);
assert.equal(migrations.currentVersion, 1);
assert.equal(migrations.latestAvailableVersion, 1);
assert.deepEqual(migrations.pendingVersions, []);
assert.deepEqual(database.prepare("SELECT * FROM app_settings").get(), {
id: 1,
revision: 1,
default_duration_ms: 7000,
default_background_mode: "blurred",
default_solid_background_color: "#000000",
default_background_darkened: 0,
default_blur_px: 20,
crossfade_duration_ms: 1000,
updated_at: database.prepare("SELECT updated_at FROM app_settings").get()
.updated_at,
});
const playback = database.prepare("SELECT * FROM playback_state").get();
assert.equal(playback.output_id, "global");
assert.equal(playback.mode, "on");
assert.equal(playback.pending_playlist_id, null);
assert.equal(playback.pending_playlist_requested_at, null);
assert.ok(columnNames(database, "playlist_entries").includes(
"background_darkened_override",
));
});
test("migrations are idempotent and report their status", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const rerun = runMigrations(database);
assert.equal(rerun.applied.length, 1);
assert.deepEqual(rerun.pendingVersions, []);
assert.deepEqual(migrationStatus(database), rerun);
});
test("a failed migration rolls back its schema and version record", (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-migrations-"));
const migrationPath = path.join(directory, "001_test.sql");
fs.writeFileSync(
migrationPath,
"CREATE TABLE should_roll_back (id INTEGER); THIS IS NOT SQL;",
);
const database = openDatabase({ path: ":memory:" });
t.after(() => {
database.close();
fs.rmSync(directory, { recursive: true, force: true });
});
assert.throws(() => runMigrations(database, directory));
assert.equal(
database
.prepare(
"SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'should_roll_back'",
)
.get().count,
0,
);
assert.equal(
database.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get().count,
0,
);
fs.writeFileSync(migrationPath, "CREATE TABLE recovered (id INTEGER);");
const status = runMigrations(database, directory);
assert.equal(status.currentVersion, 1);
assert.equal(
database
.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'recovered'")
.get().count,
1,
);
});
test("a library asset can occur only once in a given playlist", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const now = Date.now();
database
.prepare(
`INSERT INTO assets
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
)
.run("asset-1", "a".repeat(64), "Welcome", "welcome.png", now, now);
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES (?, ?, ?, ?)`,
)
.run("playlist-1", "Sunday", now, now);
const insert = database.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
);
insert.run("entry-1", "playlist-1", "asset-1", 0, now, now);
assert.throws(
() => insert.run("entry-2", "playlist-1", "asset-1", 1, now, now),
/UNIQUE constraint failed/,
);
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES (?, ?, ?, ?)`,
)
.run("playlist-2", "Midweek", now, now);
assert.doesNotThrow(() =>
insert.run("entry-3", "playlist-2", "asset-1", 0, now, now),
);
assert.throws(
() => database.prepare("DELETE FROM assets WHERE id = 'asset-1'").run(),
/FOREIGN KEY constraint failed/,
);
});
test("darkening overrides accept inherited, off, and on only", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const now = Date.now();
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES ('playlist-1', 'Sunday', ?, ?)`,
)
.run(now, now);
const insertAsset = database.prepare(
`INSERT INTO assets
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
);
const insertEntry = database.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, background_darkened_override, position, created_at, updated_at)
VALUES (?, 'playlist-1', ?, ?, ?, ?, ?)`,
);
for (const [index, override] of [null, 0, 1].entries()) {
const assetId = `asset-${index}`;
insertAsset.run(
assetId,
String(index).repeat(64),
`Asset ${index}`,
`asset-${index}.png`,
now,
now,
);
assert.doesNotThrow(() =>
insertEntry.run(
`entry-${index}`,
assetId,
override,
index,
now,
now,
),
);
}
insertAsset.run(
"asset-invalid",
"f".repeat(64),
"Invalid override",
"invalid.png",
now,
now,
);
assert.throws(
() => insertEntry.run("entry-invalid", "asset-invalid", 2, 3, now, now),
/CHECK constraint failed/,
);
assert.throws(
() =>
database
.prepare("UPDATE app_settings SET default_background_darkened = 2 WHERE id = 1")
.run(),
/CHECK constraint failed/,
);
});
test("pending playlist activation requires a playlist and request time together", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const now = Date.now();
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES ('playlist-1', 'Sunday', ?, ?)`,
)
.run(now, now);
assert.throws(
() =>
database
.prepare(
"UPDATE playback_state SET pending_playlist_id = 'playlist-1' WHERE output_id = 'global'",
)
.run(),
/CHECK constraint failed/,
);
assert.doesNotThrow(() =>
database
.prepare(
`UPDATE playback_state
SET pending_playlist_id = 'playlist-1', pending_playlist_requested_at = ?
WHERE output_id = 'global'`,
)
.run(now),
);
assert.throws(
() => database.prepare("DELETE FROM playlists WHERE id = 'playlist-1'").run(),
/FOREIGN KEY constraint failed/,
);
assert.throws(
() =>
database
.prepare(
`UPDATE playback_state
SET pending_playlist_id = 'missing', pending_playlist_requested_at = ?
WHERE output_id = 'global'`,
)
.run(now),
/FOREIGN KEY constraint failed/,
);
});
+586
View File
@@ -0,0 +1,586 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const {
DISPLAY_ERROR_SEVERITY,
createDisplayService,
errorConditionKey,
parseDisplayError,
} = require("../src/displays/service");
const { createNotificationService } = require("../src/notifications");
const BASE_TIME = 2_000_000_000_000;
const THRESHOLD = 45_000;
function createClock(initial = BASE_TIME) {
let current = initial;
let sequence = 0;
const timers = new Set();
return {
now: () => current,
set(value) {
current = value;
},
setTimeout(callback, delay) {
const timer = { at: current + delay, callback, cleared: false, id: ++sequence };
timers.add(timer);
return timer;
},
clearTimeout(timer) {
if (timer) timer.cleared = true;
},
runTo(value) {
current = value;
for (let guard = 0; guard < 200; guard += 1) {
const timer = [...timers]
.filter((candidate) => !candidate.cleared && !candidate.fired && candidate.at <= current)
.sort((left, right) => left.at - right.at || left.id - right.id)[0];
if (!timer) return;
timer.fired = true;
timer.callback();
}
throw new Error("timer loop did not settle");
},
};
}
function heartbeatPayload(overrides = {}) {
return {
protocolVersion: 1,
clientId: "lobby-display",
outputId: "global",
activePlaylistId: "playlist-a",
playlistRevision: 12,
playbackRevision: 42,
currentEntryId: "entry-a",
currentAssetId: "asset-a",
cachedAssetCount: 10,
playableAssetCount: 10,
mostRecentError: null,
...overrides,
};
}
function createHarness({ start = true } = {}) {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
const clock = createClock();
const events = [];
let idCounter = 0;
const notifications = createNotificationService({
database,
webhookUrl: "http://127.0.0.1:9/hook",
now: clock.now,
setTimeoutFn: clock.setTimeout,
clearTimeoutFn: clock.clearTimeout,
createId: () => `note-${++idCounter}`,
fetchFn: () => Promise.resolve({ status: 200 }),
logger: { warn() {} },
});
function build() {
const service = createDisplayService({
database,
notifications,
now: clock.now,
setTimeoutFn: clock.setTimeout,
clearTimeoutFn: clock.clearTimeout,
offlineThresholdMs: THRESHOLD,
onStatusChanged: (event) => events.push(event),
logger: { warn() {} },
});
if (start) service.start();
return service;
}
return {
build,
clock,
database,
events,
notifications,
service: build(),
outbox: () =>
database.prepare("SELECT * FROM notification_outbox ORDER BY created_at, id").all(),
incidents: () =>
database.prepare("SELECT * FROM notification_incidents ORDER BY opened_at").all(),
rows: () => database.prepare("SELECT * FROM display_clients").all(),
};
}
test("a first heartbeat creates the display and later heartbeats update the same record", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const first = harness.service.heartbeat(heartbeatPayload());
assert.equal(first.status, "online");
assert.equal(first.clientId, "lobby-display");
assert.equal(harness.rows().length, 1);
assert.equal(harness.events.at(-1).reason, "first_seen");
harness.clock.set(BASE_TIME + 12_000);
const second = harness.service.heartbeat(
heartbeatPayload({ playbackRevision: 43, cachedAssetCount: 11 }),
);
assert.equal(harness.rows().length, 1, "the persistent client ID is the display identity");
assert.equal(second.playbackRevision, 43);
assert.equal(second.cachedAssetCount, 11);
assert.equal(
new Date(second.firstSeenAt).getTime(),
BASE_TIME,
"first_seen_at is preserved across heartbeats",
);
assert.equal(new Date(second.lastSeenAt).getTime(), BASE_TIME + 12_000);
assert.equal(
harness.events.filter((event) => event.reason === "first_seen").length,
1,
"a steady heartbeat creates no realtime churn",
);
});
test("the same display keeps its identity across a new Socket.IO connection", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
// A browser reconnect produces a new socket but the same persisted client ID.
harness.clock.set(BASE_TIME + 20_000);
harness.service.heartbeat(heartbeatPayload());
assert.equal(harness.rows().length, 1);
assert.equal(harness.service.list().length, 1);
assert.equal(harness.service.list()[0].status, "online");
});
test("malformed telemetry is rejected safely and never creates a display", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const invalid = [
null,
"string",
[],
{},
heartbeatPayload({ protocolVersion: 2 }),
heartbeatPayload({ protocolVersion: undefined }),
heartbeatPayload({ clientId: "" }),
heartbeatPayload({ clientId: " " }),
heartbeatPayload({ clientId: 42 }),
heartbeatPayload({ clientId: "x".repeat(300) }),
heartbeatPayload({ cachedAssetCount: -1 }),
heartbeatPayload({ cachedAssetCount: 1.5 }),
heartbeatPayload({ playbackRevision: -5 }),
heartbeatPayload({ mostRecentError: { nested: true } }),
];
for (const payload of invalid) {
assert.throws(() => harness.service.heartbeat(payload), TypeError);
}
assert.equal(harness.rows().length, 0);
});
test("an oversized error string is bounded before it is stored", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const result = harness.service.heartbeat(
heartbeatPayload({ mostRecentError: "E".repeat(5_000_000) }),
);
assert.ok(result.mostRecentError.length <= 500, "a client cannot store megabytes");
assert.ok(harness.rows()[0].last_error.length <= 500);
});
test("heartbeats never change playback or playlist revisions", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
const playbackBefore = harness.database
.prepare("SELECT revision, updated_at FROM playback_state WHERE output_id = 'global'")
.get();
for (let beat = 0; beat < 5; beat += 1) {
harness.clock.set(BASE_TIME + beat * 10_000);
harness.service.heartbeat(heartbeatPayload({ playbackRevision: 99 }));
}
assert.deepEqual(
harness.database
.prepare("SELECT revision, updated_at FROM playback_state WHERE output_id = 'global'")
.get(),
playbackBefore,
"telemetry is read-only reporting",
);
});
test("one missed heartbeat does not mark a display offline before the threshold", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
// Two heartbeat intervals have passed but the 45s threshold has not.
harness.clock.runTo(BASE_TIME + 30_000);
assert.equal(harness.service.list()[0].status, "online");
assert.equal(harness.incidents().length, 0);
harness.clock.runTo(BASE_TIME + THRESHOLD - 1);
assert.equal(harness.service.list()[0].status, "online");
assert.equal(harness.incidents().length, 0);
});
test("crossing the threshold opens exactly one offline incident that never repeats", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
harness.clock.runTo(BASE_TIME + THRESHOLD);
assert.equal(harness.service.list()[0].status, "offline");
const incidents = harness.incidents();
assert.equal(incidents.length, 1);
assert.equal(incidents[0].event, "display.offline");
assert.equal(incidents[0].condition_key, "display:lobby-display");
assert.equal(JSON.parse(incidents[0].context_json).clientId, "lobby-display");
assert.equal(harness.outbox().length, 1);
assert.equal(harness.events.at(-1).reason, "offline");
// Staying offline for a long time must not produce more notifications.
harness.clock.runTo(BASE_TIME + 30 * 60_000);
assert.equal(harness.incidents().length, 1);
assert.equal(harness.outbox().length, 1);
assert.equal(
harness.events.filter((event) => event.reason === "offline").length,
1,
);
});
test("a returning heartbeat recovers the display exactly once", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
harness.clock.runTo(BASE_TIME + THRESHOLD);
assert.equal(harness.outbox().length, 1);
harness.clock.set(BASE_TIME + 60_000);
const recovered = harness.service.heartbeat(heartbeatPayload());
assert.equal(recovered.status, "online");
assert.equal(harness.incidents()[0].state, "recovered");
assert.equal(harness.outbox().length, 2);
assert.equal(harness.outbox()[1].transition, "recovery");
assert.equal(harness.events.at(-1).reason, "recovered");
// Continued healthy heartbeats add nothing further.
harness.clock.set(BASE_TIME + 70_000);
harness.service.heartbeat(heartbeatPayload());
assert.equal(harness.outbox().length, 2);
assert.equal(
harness.events.filter((event) => event.reason === "recovered").length,
1,
);
// A later disappearance is a new episode with its own single notification.
harness.clock.runTo(BASE_TIME + 70_000 + THRESHOLD);
assert.equal(harness.incidents().length, 2);
assert.equal(harness.outbox().length, 3);
});
test("a restart preserves known displays and cannot manufacture offline alerts", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
harness.service.stop();
// The API was down for well over the threshold. A healthy display simply
// has not had the chance to report yet, so startup stays silent until a
// full threshold has elapsed since the restart.
harness.clock.set(BASE_TIME + 10 * 60_000);
const restarted = harness.build();
assert.equal(harness.rows().length, 1, "display history survives a restart");
assert.equal(restarted.list()[0].status, "offline", "status still reflects staleness");
assert.equal(harness.incidents().length, 0, "no notification storm after a restart");
harness.clock.runTo(BASE_TIME + 10 * 60_000 + 20_000);
assert.equal(harness.incidents().length, 0, "still inside the startup grace period");
restarted.heartbeat(heartbeatPayload());
assert.equal(restarted.list()[0].status, "online");
assert.equal(harness.incidents().length, 0, "a display that comes back was never offline");
});
test("a display still missing after the startup grace period is reported once", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
harness.service.stop();
harness.clock.set(BASE_TIME + 10 * 60_000);
harness.build();
harness.clock.runTo(BASE_TIME + 10 * 60_000 + THRESHOLD);
assert.equal(harness.incidents().length, 1);
assert.equal(harness.incidents()[0].event, "display.offline");
assert.equal(harness.outbox().length, 1);
});
test("an already-open offline incident is not duplicated by a restart", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
harness.clock.runTo(BASE_TIME + THRESHOLD);
assert.equal(harness.incidents().length, 1);
harness.service.stop();
harness.clock.set(BASE_TIME + 20 * 60_000);
harness.build();
harness.clock.runTo(BASE_TIME + 20 * 60_000 + THRESHOLD + 1_000);
assert.equal(harness.incidents().length, 1, "the same episode stays open");
assert.equal(harness.outbox().length, 1);
});
test("a changed error is surfaced as a meaningful status update", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
harness.events.length = 0;
harness.clock.set(BASE_TIME + 10_000);
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "decode failed for asset-a" }));
assert.equal(harness.events.length, 1);
assert.equal(harness.events[0].reason, "error_changed");
assert.equal(harness.events[0].mostRecentError, "decode failed for asset-a");
// The same error repeating is not a new event.
harness.clock.set(BASE_TIME + 20_000);
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "decode failed for asset-a" }));
assert.equal(harness.events.length, 1);
// An arbitrary browser string is telemetry only, never a notification.
assert.equal(harness.incidents().length, 0);
assert.equal(harness.outbox().length, 0);
});
test("display status derives from heartbeat freshness for every known client", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload({ clientId: "lobby" }));
harness.clock.set(BASE_TIME + 40_000);
harness.service.heartbeat(heartbeatPayload({ clientId: "narthex" }));
harness.clock.set(BASE_TIME + 50_000);
const displays = Object.fromEntries(
harness.service.list().map((display) => [display.clientId, display]),
);
assert.equal(displays.lobby.status, "offline");
assert.equal(displays.narthex.status, "online");
assert.equal(harness.service.status().online, 1);
assert.equal(harness.service.status().offline, 1);
assert.equal(harness.service.status().known, 2);
const serialized = JSON.stringify(harness.service.list());
assert.doesNotMatch(serialized, /socket|sid|transport/i);
});
test("a structured display error round-trips without loosening validation", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
// The real display serializes its structured error to bounded JSON, which the
// existing string validation already accepts.
const structured = JSON.stringify({
kind: "slide_decode_failed",
assetId: "asset-a",
entryId: "entry-a",
message: "Could not decode /media/assets/asset-a/display/abc.webp",
at: BASE_TIME,
});
const result = harness.service.heartbeat(
heartbeatPayload({ mostRecentError: structured }),
);
assert.equal(result.mostRecentError.kind, "slide_decode_failed");
assert.equal(result.mostRecentError.assetId, "asset-a");
assert.equal(result.mostRecentError.entryId, "entry-a");
assert.equal(
harness.rows()[0].last_error,
structured,
"the column still stores bounded text; no migration is required",
);
// Non-JSON telemetry stays a plain string.
harness.clock.set(BASE_TIME + 5_000);
assert.equal(
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "plain failure" }))
.mostRecentError,
"plain failure",
);
// Input validation is unchanged: objects are still rejected outright.
assert.throws(
() => harness.service.heartbeat(heartbeatPayload({ mostRecentError: { kind: "x" } })),
TypeError,
);
assert.equal(parseDisplayError(null), null);
assert.equal(parseDisplayError("{not json"), "{not json");
assert.equal(parseDisplayError('{"no":"kind"}'), '{"no":"kind"}');
});
function structuredError(kind, overrides = {}) {
return JSON.stringify({
kind,
assetId: null,
entryId: null,
message: `${kind} detail`,
at: BASE_TIME,
...overrides,
});
}
test("a structured display error opens exactly one incident and never repeats", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
for (let beat = 0; beat < 6; beat += 1) {
harness.clock.set(BASE_TIME + beat * 12_000);
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("slide_decode_failed", { assetId: "asset-a" }),
}),
);
}
const incidents = harness.incidents();
assert.equal(incidents.length, 1, "a persisting error is reported once, not per heartbeat");
assert.equal(incidents[0].event, "display.slide_decode_failed");
assert.equal(incidents[0].severity, "warning");
assert.equal(
incidents[0].condition_key,
errorConditionKey("lobby-display", { kind: "slide_decode_failed", assetId: "asset-a" }),
);
assert.equal(JSON.parse(incidents[0].context_json).assetId, "asset-a");
assert.equal(harness.outbox().length, 1);
});
test("a resolved display error recovers exactly once", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("asset_fetch_failed", { assetId: "asset-a" }),
}),
);
assert.equal(harness.outbox().length, 1);
for (let beat = 1; beat <= 3; beat += 1) {
harness.clock.set(BASE_TIME + beat * 12_000);
harness.service.heartbeat(heartbeatPayload({ mostRecentError: null }));
}
const incidents = harness.incidents();
assert.equal(incidents.length, 1);
assert.equal(incidents[0].state, "recovered");
assert.equal(harness.outbox().length, 2, "one failure and one recovery");
assert.equal(harness.outbox()[1].transition, "recovery");
});
test("an error moving to another asset recovers the old condition and opens the new one", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("asset_fetch_failed", { assetId: "asset-a" }),
}),
);
harness.clock.set(BASE_TIME + 12_000);
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("asset_fetch_failed", { assetId: "asset-b" }),
}),
);
const byKey = Object.fromEntries(
harness.incidents().map((incident) => [incident.condition_key, incident]),
);
const keyA = errorConditionKey("lobby-display", {
kind: "asset_fetch_failed",
assetId: "asset-a",
});
const keyB = errorConditionKey("lobby-display", {
kind: "asset_fetch_failed",
assetId: "asset-b",
});
assert.equal(byKey[keyA].state, "recovered", "the resolved asset condition closes");
assert.equal(byKey[keyB].state, "open", "the new asset condition opens");
assert.equal(harness.outbox().length, 3);
});
test("only validated error codes become incidents", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
// Arbitrary browser text is telemetry, never a Home Assistant notification.
harness.service.heartbeat(
heartbeatPayload({ mostRecentError: "TypeError: undefined is not a function" }),
);
assert.equal(harness.incidents().length, 0);
assert.equal(
harness.service.list()[0].mostRecentError,
"TypeError: undefined is not a function",
"the raw telemetry is still visible to operators",
);
// A structured payload with an unrecognised code is equally inert.
harness.clock.set(BASE_TIME + 12_000);
harness.service.heartbeat(
heartbeatPayload({ mostRecentError: structuredError("something_invented") }),
);
assert.equal(harness.incidents().length, 0);
assert.equal(harness.outbox().length, 0);
assert.equal(harness.service.list()[0].mostRecentError.kind, "something_invented");
});
test("a manifest that cannot render locally is critical", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(
heartbeatPayload({ mostRecentError: structuredError("manifest_invalid") }),
);
assert.equal(harness.incidents()[0].severity, "critical");
assert.equal(DISPLAY_ERROR_SEVERITY.manifest_invalid, "critical");
assert.equal(DISPLAY_ERROR_SEVERITY.offline_capability_unavailable, "warning");
});
test("display error incidents never disturb the display offline condition", (t) => {
const harness = createHarness();
t.after(() => harness.database.close());
harness.service.heartbeat(heartbeatPayload());
// The display goes offline, then returns while reporting a slide problem.
harness.clock.runTo(BASE_TIME + THRESHOLD);
assert.equal(harness.incidents().length, 1);
assert.equal(harness.incidents()[0].event, "display.offline");
harness.clock.set(BASE_TIME + 60_000);
harness.service.heartbeat(
heartbeatPayload({
mostRecentError: structuredError("slide_decode_failed", { assetId: "asset-a" }),
}),
);
const offline = harness
.incidents()
.find((incident) => incident.condition_key === "display:lobby-display");
assert.equal(offline.state, "recovered", "returning still recovers the offline condition once");
const decode = harness
.incidents()
.find((incident) => incident.event === "display.slide_decode_failed");
assert.equal(decode.state, "open");
assert.equal(
harness.incidents().filter((incident) => incident.event === "display.offline").length,
1,
);
});

Some files were not shown because too many files have changed in this diff Show More