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
69 changed files with 11407 additions and 185 deletions
+16 -1
View File
@@ -38,9 +38,24 @@ UPLOAD_MAX_FILES=20
IMAGE_MAX_INPUT_PIXELS=40000000
# UPLOAD_TEMP_DIRECTORY=/tmp/announcements-uploads
# Optional integrations. Leave blank to disable them during Phase 2.
# 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
+553
View File
@@ -20,6 +20,15 @@ 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`
@@ -181,6 +190,542 @@ 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`,
@@ -193,3 +738,11 @@ 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
+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 } },
},
])
+8 -1
View File
@@ -10,15 +10,22 @@
"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"] } }],
});
+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();
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>,
);
+14 -1
View File
@@ -13,7 +13,13 @@ services:
api:
condition: service_healthy
networks:
- app
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
@@ -53,8 +59,13 @@ services:
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:
@@ -105,6 +116,8 @@ networks:
app:
storage:
internal: true
tabernet:
external: true
volumes:
app-data:
+348
View File
@@ -10,6 +10,18 @@ importers:
client:
dependencies:
'@dnd-kit/core':
specifier: 6.3.1
version: 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/sortable':
specifier: 10.0.0
version: 10.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
'@dnd-kit/utilities':
specifier: 3.2.2
version: 3.2.2([email protected])
'@fontsource/roboto':
specifier: 5.2.5
version: 5.2.5
react:
specifier: ^19.2.6
version: 19.2.8
@@ -23,6 +35,9 @@ importers:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1([email protected])
'@playwright/test':
specifier: 1.62.1
version: 1.62.1
'@types/react':
specifier: ^19.2.14
version: 19.2.18
@@ -62,6 +77,9 @@ importers:
express:
specifier: ^5.2.1
version: 5.2.1
mqtt:
specifier: 5.15.2
version: 5.15.2
multer:
specifier: 2.0.2
version: 2.0.2
@@ -201,6 +219,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/[email protected]':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -213,6 +235,28 @@ packages:
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/[email protected]':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@emnapi/[email protected]':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
@@ -255,6 +299,9 @@ packages:
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@fontsource/[email protected]':
resolution: {integrity: sha512-70r2UZ0raqLn5W+sPeKhqlf8wGvUXFWlofaDlcbt/S3d06+17gXKr3VNqDODB0I1ASme3dGT5OJj9NABt7OTZQ==}
'@humanfs/[email protected]':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -456,6 +503,11 @@ packages:
'@oxc-project/[email protected]':
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
'@playwright/[email protected]':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'}
hasBin: true
'@rolldown/[email protected]':
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -599,6 +651,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
'@types/[email protected]':
resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==}
'@types/[email protected]':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -615,6 +670,10 @@ packages:
babel-plugin-react-compiler:
optional: true
[email protected]:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
[email protected]:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -643,6 +702,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
[email protected]:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
[email protected]:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
@@ -656,6 +718,9 @@ packages:
resolution: {integrity: sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==}
engines: {node: '>=22'}
[email protected]:
resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==}
[email protected]:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@@ -667,6 +732,9 @@ packages:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
[email protected]:
resolution: {integrity: sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==}
[email protected]:
resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -675,6 +743,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
[email protected]:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
[email protected]:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
@@ -694,6 +765,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
[email protected]:
resolution: {integrity: sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==}
[email protected]:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
@@ -861,6 +935,14 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
[email protected]:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
[email protected]:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@@ -874,6 +956,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
[email protected]:
resolution: {integrity: sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==}
engines: {node: '>=18.2.0'}
[email protected]:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -910,6 +996,11 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -950,6 +1041,9 @@ packages:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
[email protected]:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@@ -964,6 +1058,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
[email protected]:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -975,6 +1072,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
[email protected]:
resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==}
engines: {node: '>= 12'}
[email protected]:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@@ -993,6 +1094,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
[email protected]:
resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==}
[email protected]:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1100,6 +1204,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -1146,6 +1253,14 @@ packages:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==}
[email protected]:
resolution: {integrity: sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==}
engines: {node: '>=16.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1177,6 +1292,9 @@ packages:
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==}
[email protected]:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1226,6 +1344,16 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
engines: {node: '>=20'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
engines: {node: '>=20'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -1234,6 +1362,13 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
[email protected]:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
[email protected]:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
[email protected]:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -1267,6 +1402,13 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
[email protected]:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
[email protected]:
resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1338,6 +1480,10 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
[email protected]:
resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==}
@@ -1353,10 +1499,18 @@ packages:
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
engines: {node: '>=10.2.0'}
[email protected]:
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
[email protected]:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -1469,6 +1623,18 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==}
[email protected]:
resolution: {integrity: sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==}
[email protected]:
resolution: {integrity: sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==}
[email protected]:
resolution: {integrity: sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==}
[email protected]:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -1764,6 +1930,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.8
'@babel/[email protected]': {}
'@babel/[email protected]':
dependencies:
'@babel/code-frame': 7.29.7
@@ -1787,6 +1955,31 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@dnd-kit/[email protected]([email protected])':
dependencies:
react: 19.2.8
tslib: 2.8.1
'@dnd-kit/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@dnd-kit/accessibility': 3.1.1([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.8
react-dom: 19.2.8([email protected])
tslib: 2.8.1
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
dependencies:
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
'@dnd-kit/utilities': 3.2.2([email protected])
react: 19.2.8
tslib: 2.8.1
'@dnd-kit/[email protected]([email protected])':
dependencies:
react: 19.2.8
tslib: 2.8.1
'@emnapi/[email protected]':
dependencies:
tslib: 2.8.1
@@ -1826,6 +2019,8 @@ snapshots:
'@eslint/core': 1.2.1
levn: 0.4.1
'@fontsource/[email protected]': {}
'@humanfs/[email protected]':
dependencies:
'@humanfs/types': 0.15.0
@@ -1969,6 +2164,10 @@ snapshots:
'@oxc-project/[email protected]': {}
'@playwright/[email protected]':
dependencies:
playwright: 1.62.1
'@rolldown/[email protected]':
optional: true
@@ -2070,6 +2269,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/[email protected]':
dependencies:
'@types/node': 25.8.0
'@types/[email protected]':
dependencies:
'@types/node': 25.8.0
@@ -2079,6 +2282,10 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.1(@types/[email protected])
[email protected]:
dependencies:
event-target-shim: 5.0.1
[email protected]:
dependencies:
mime-types: 2.1.35
@@ -2106,6 +2313,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2114,6 +2323,13 @@ snapshots:
dependencies:
node-addon-api: 8.9.2
[email protected]:
dependencies:
'@types/readable-stream': 4.0.24
buffer: 6.0.3
inherits: 2.0.4
readable-stream: 4.7.0
[email protected]:
dependencies:
bytes: 3.1.2
@@ -2134,6 +2350,13 @@ snapshots:
dependencies:
balanced-match: 4.0.4
[email protected]:
dependencies:
'@babel/runtime': 7.29.7
fast-unique-numbers: 9.0.27
tslib: 2.8.1
worker-factory: 7.0.50
[email protected]:
dependencies:
baseline-browser-mapping: 2.11.14
@@ -2144,6 +2367,11 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
[email protected]:
dependencies:
streamsearch: 1.1.0
@@ -2162,6 +2390,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
buffer-from: 1.1.2
@@ -2344,6 +2574,10 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
accepts: 2.0.0
@@ -2383,6 +2617,11 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@babel/runtime': 7.29.7
tslib: 2.8.1
[email protected]([email protected]):
optionalDependencies:
picomatch: 4.0.5
@@ -2418,6 +2657,9 @@ snapshots:
[email protected]: {}
[email protected]:
optional: true
[email protected]:
optional: true
@@ -2457,6 +2699,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -2475,12 +2719,16 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2493,6 +2741,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2567,6 +2817,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
[email protected]: {}
[email protected]:
dependencies:
yallist: 3.1.1
@@ -2601,6 +2853,37 @@ snapshots:
dependencies:
minimist: 1.2.8
[email protected]:
dependencies:
bl: 6.1.6
debug: 4.4.3
process-nextick-args: 2.0.1
transitivePeerDependencies:
- supports-color
[email protected]:
dependencies:
'@types/readable-stream': 4.0.24
'@types/ws': 8.18.1
commist: 3.2.0
concat-stream: 2.0.0
debug: 4.4.3
help-me: 5.0.0
lru-cache: 10.4.3
minimist: 1.2.8
mqtt-packet: 9.0.2
number-allocator: 1.0.14
readable-stream: 4.7.0
rfdc: 1.4.1
socks: 2.8.9
split2: 4.2.0
worker-timers: 8.0.34
ws: 8.21.3
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
[email protected]: {}
[email protected]:
@@ -2625,6 +2908,13 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
debug: 4.4.3
js-sdsl: 4.3.0
transitivePeerDependencies:
- supports-color
[email protected]: {}
[email protected]: {}
@@ -2666,6 +2956,14 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.62.1
optionalDependencies:
fsevents: 2.3.2
[email protected]:
dependencies:
nanoid: 3.3.18
@@ -2674,6 +2972,10 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
forwarded: 0.2.0
@@ -2707,6 +3009,16 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
[email protected]:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
[email protected]: {}
[email protected]:
dependencies:
'@oxc-project/types': 0.144.0
@@ -2840,6 +3152,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
[email protected]: {}
[email protected]:
dependencies:
debug: 4.4.3
@@ -2881,8 +3195,15 @@ snapshots:
- supports-color
- utf-8-validate
[email protected]:
dependencies:
ip-address: 10.5.0
smart-buffer: 4.2.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2952,6 +3273,33 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@babel/runtime': 7.29.7
fast-unique-numbers: 9.0.27
tslib: 2.8.1
[email protected]:
dependencies:
'@babel/runtime': 7.29.7
broker-factory: 3.1.15
fast-unique-numbers: 9.0.27
tslib: 2.8.1
worker-timers-worker: 9.0.15
[email protected]:
dependencies:
'@babel/runtime': 7.29.7
tslib: 2.8.1
worker-factory: 7.0.50
[email protected]:
dependencies:
'@babel/runtime': 7.29.7
tslib: 2.8.1
worker-timers-broker: 8.0.18
worker-timers-worker: 9.0.15
[email protected]: {}
[email protected]: {}
+1
View File
@@ -17,6 +17,7 @@
"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"
+16
View File
@@ -6,6 +6,7 @@ const {
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 {
@@ -36,11 +37,14 @@ function safeNotifier(notifier, logger, failureMessage) {
function createApp({
config,
database,
displayService,
imageProcessor,
integrations = {},
lifecycle,
logger = console,
migrations,
now = Date.now,
onAssetProcessingFailed = () => {},
onPlaylistsChanged = () => {},
playbackService: suppliedPlaybackService,
storage,
@@ -72,9 +76,15 @@ function createApp({
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({
@@ -99,8 +109,11 @@ function createApp({
const detailedHealthRouterOptions = {
config,
database,
displayService,
integrations,
lifecycle,
migrations,
playbackService,
storage,
};
app.use(
@@ -136,6 +149,9 @@ function createApp({
}),
);
app.use("/api/playback", createPlaybackRouter(playbackService));
if (displayService) {
app.use("/api/displays", createDisplayRouter(displayService));
}
app.use("/media", createMediaRouter(assetService));
app.use((request, response) => {
+16
View File
@@ -163,6 +163,7 @@ function createAssetService({
createId = crypto.randomUUID,
wait = delay,
concurrentUploadWaitMs = CONCURRENT_UPLOAD_WAIT_MS,
onProcessingFailed = () => {},
}) {
const assetSelect = `
SELECT a.*,
@@ -434,11 +435,26 @@ function createAssetService({
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 {
+46 -2
View File
@@ -82,6 +82,23 @@ function url(env, name, { required = false, protocols } = {}) {
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)) {
@@ -199,12 +216,31 @@ function loadConfig(env = process.env) {
}),
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,
}),
}),
});
}
@@ -240,11 +276,19 @@ function redactedConfig(config) {
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),
configured: Boolean(config.mqtt?.url),
topicPrefix: config.mqtt?.topicPrefix ?? null,
reconnectPeriodMs: config.mqtt?.reconnectPeriodMs ?? null,
},
notifications: {
configured: Boolean(config.notifications.homeAssistantWebhookUrl),
configured: Boolean(config.notifications?.homeAssistantWebhookUrl),
webhookTimeoutMs: config.notifications?.webhookTimeoutMs ?? null,
},
displays: {
offlineThresholdMs: config.displays?.offlineThresholdMs ?? null,
},
};
}
+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,
};
+60 -16
View File
@@ -87,7 +87,46 @@ function createHealthRouter({ database, lifecycle, migrations, storage }) {
return router;
}
function createDetailedHealthRouter({ config, database, lifecycle, migrations, storage }) {
// 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) => {
@@ -99,11 +138,22 @@ function createDetailedHealthRouter({ config, database, lifecycle, migrations, s
migrations.pendingVersions.length === 0;
const configuration = redactedConfig(config);
const status = !ready
? "not_ready"
: storageCheck.status === "healthy"
? "ok"
: "degraded";
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,
@@ -118,16 +168,10 @@ function createDetailedHealthRouter({ config, database, lifecycle, migrations, s
...storageCheck,
configured: configuration.storage.configured,
},
mqtt: {
status: "not_started",
configured: configuration.mqtt.configured,
},
notifications: {
status: "not_started",
configured: configuration.notifications.configured,
},
playback: { status: "not_started" },
displays: { status: "not_started" },
mqtt: mqttCheck,
notifications: notificationCheck,
playback: playbackStatus(playbackService),
displays: displayCheck,
},
});
});
+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,
};
+111 -2
View File
@@ -3,7 +3,15 @@ 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");
@@ -27,6 +35,8 @@ function createRuntime({
config,
imageProcessor: suppliedImageProcessor,
logger = console,
mqttConnect,
notificationFetch,
storage: suppliedStorage,
}) {
const database = openDatabase(config.database);
@@ -42,10 +52,41 @@ function createRuntime({
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 });
storage =
suppliedStorage ||
createStorageService(config.storage, {
logger,
onHealthChanged(health) {
safely(
() => observeStorageHealth(notifications, health),
"Storage incident observation failed",
);
},
});
imageProcessor =
suppliedImageProcessor ||
createImageProcessor({ maxInputPixels: config.processing.maxInputPixels });
@@ -56,6 +97,8 @@ function createRuntime({
let app;
let io = null;
let playbackService;
let mqttService;
let displayService;
function emitSocketEvent(eventName, payload) {
try {
io?.emit(eventName, payload);
@@ -74,17 +117,50 @@ function createRuntime({
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);
},
@@ -108,7 +184,20 @@ function createRuntime({
} 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}`);
});
});
@@ -132,6 +221,11 @@ function createRuntime({
.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(() => {
@@ -141,6 +235,8 @@ function createRuntime({
})
.catch((error) => {
playbackService.stop();
notifications.stop();
displayService.stop();
storage.stop();
database.close();
throw error;
@@ -156,6 +252,13 @@ function createRuntime({
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 = () => {
@@ -185,7 +288,10 @@ function createRuntime({
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;
}
@@ -193,10 +299,13 @@ function createRuntime({
app,
config,
database,
displayService,
httpServer,
io,
lifecycle,
migrations,
mqttService,
notifications,
playbackService,
storage,
imageProcessor,
+28 -1
View File
@@ -137,7 +137,15 @@ function createS3Client(config) {
return new S3Client(options);
}
function createStorageService(config, { client, logger = console, now = () => new Date() } = {}) {
function createStorageService(
config,
{
client,
logger = console,
now = () => new Date(),
onHealthChanged = () => {},
} = {},
) {
const s3 = client || createS3Client(config);
let ensurePromise = null;
let healthTimer = null;
@@ -157,16 +165,33 @@ function createStorageService(config, { client, logger = console, now = () => ne
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);
}
@@ -174,6 +199,7 @@ function createStorageService(config, { client, logger = console, now = () => ne
if (sequence < appliedHealthSequence) return publicHealth(currentHealth);
appliedHealthSequence = sequence;
bucketReady = false;
const previousStatus = currentHealth.status;
currentHealth = {
status: "degraded",
checkedAt: now().toISOString(),
@@ -181,6 +207,7 @@ function createStorageService(config, { client, logger = console, now = () => ne
code: "STORAGE_UNAVAILABLE",
message: "Object storage is unavailable",
};
announceHealth(previousStatus);
return publicHealth(currentHealth);
}
+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,
);
});
+430
View File
@@ -0,0 +1,430 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { loadConfig } = require("../src/config");
const { createRuntime } = require("../src/runtime");
const silentLogger = { info() {}, warn() {}, error() {} };
function testConfig(overrides = {}) {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
SHUTDOWN_TIMEOUT_MS: "1000",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_BUCKET: "announcements-test",
MQTT_URL: "mqtt://broker:1883",
MQTT_PASSWORD: "mqtt-password-that-must-not-leak",
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/private-id",
...overrides,
});
}
function testStorage(health = { status: "healthy", checkedAt: null, lastSuccessAt: null }) {
return {
async start() {},
stop() {},
health: () => health,
};
}
function createFakeBroker({ failPublish = false } = {}) {
const listeners = new Map();
const client = {
published: [],
subscriptions: [],
on(event, handler) {
listeners.set(event, [...(listeners.get(event) || []), handler]);
return client;
},
emit(event, ...args) {
for (const handler of listeners.get(event) || []) handler(...args);
},
publish(topic, payload, options, callback) {
if (failPublish) throw new Error("broker is gone");
client.published.push({ topic, payload: String(payload) });
callback?.(null);
},
subscribe(topic, options, callback) {
client.subscriptions.push(topic);
callback?.(null);
},
unsubscribe() {},
end(force, options, callback) {
callback?.();
},
deliver(topic, payload) {
client.emit("message", topic, Buffer.from(payload));
},
};
return client;
}
async function jsonRequest(baseUrl, route, { body, ...init } = {}) {
const response = await fetch(`${baseUrl}${route}`, {
...init,
...(body === undefined
? {}
: {
headers: { "content-type": "application/json", ...init.headers },
body: JSON.stringify(body),
}),
});
return { response, body: await response.json() };
}
function splitEnginePackets(payload) {
return payload.split("\x1e").filter(Boolean);
}
async function connectSocket(baseUrl) {
let requestNumber = 0;
const endpoint = (sid) => {
const parameters = new URLSearchParams({
EIO: "4",
transport: "polling",
t: String(requestNumber++),
});
if (sid) parameters.set("sid", sid);
return `${baseUrl}/socket.io/?${parameters}`;
};
const opening = splitEnginePackets(await (await fetch(endpoint())).text());
const { sid } = JSON.parse(opening.find((packet) => packet.startsWith("0")).slice(1));
await (
await fetch(endpoint(sid), {
method: "POST",
headers: { "content-type": "text/plain;charset=UTF-8" },
body: "40",
})
).text();
await (await fetch(endpoint(sid))).text();
return {
async send(event, payload) {
const response = await fetch(endpoint(sid), {
method: "POST",
headers: { "content-type": "text/plain;charset=UTF-8" },
body: `42${JSON.stringify([event, payload])}`,
});
await response.text();
},
async poll() {
return (await fetch(endpoint(sid), { signal: AbortSignal.timeout(3_000) })).text();
},
async close() {
await (
await fetch(endpoint(sid), {
method: "POST",
headers: { "content-type": "text/plain;charset=UTF-8" },
body: "41",
})
).text();
},
};
}
function socketEvents(payload) {
return splitEnginePackets(payload)
.filter((packet) => packet.startsWith("42"))
.map((packet) => JSON.parse(packet.slice(2)));
}
async function startRuntime(t, { broker, config, storage, notificationFetch } = {}) {
const runtime = createRuntime({
config: config || testConfig(),
logger: silentLogger,
storage: storage || testStorage(),
...(broker === null ? {} : { mqttConnect: () => broker }),
...(notificationFetch ? { notificationFetch } : {}),
});
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
broker?.emit("connect");
return { runtime, baseUrl: `http://127.0.0.1:${runtime.address.port}` };
}
async function seedPlaylist(runtime, baseUrl) {
const timestamp = Date.now();
runtime.database
.prepare(
`INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
display_object_key, display_sha256, display_content_type,
display_byte_size, display_width, display_height, created_at, updated_at
) VALUES (?, ?, ?, ?, 'ready', 'private/display/a.webp', ?, 'image/webp', 10, 1920, 1080, ?, ?)`,
)
.run("asset-one", "a".repeat(64), "One", "one.png", "b".repeat(64), timestamp, timestamp);
runtime.database
.prepare(
`INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
display_object_key, display_sha256, display_content_type,
display_byte_size, display_width, display_height, created_at, updated_at
) VALUES (?, ?, ?, ?, 'ready', 'private/display/b.webp', ?, 'image/webp', 10, 1920, 1080, ?, ?)`,
)
.run("asset-two", "c".repeat(64), "Two", "two.png", "d".repeat(64), timestamp, timestamp);
const playlist = await jsonRequest(baseUrl, "/api/playlists", {
method: "POST",
body: { name: "Integration" },
});
for (const assetId of ["asset-one", "asset-two"]) {
await jsonRequest(baseUrl, `/api/playlists/${playlist.body.id}/entries`, {
method: "POST",
body: { assetId, durationOverrideMs: 60_000 },
});
}
await jsonRequest(baseUrl, `/api/playlists/${playlist.body.id}/activate`, {
method: "POST",
});
return playlist.body;
}
test("an MQTT command and its REST equivalent reach the same playback authority", async (t) => {
const broker = createFakeBroker();
const { runtime, baseUrl } = await startRuntime(t, { broker });
await seedPlaylist(runtime, baseUrl);
const start = (await jsonRequest(baseUrl, "/api/playback")).body;
assert.equal(start.mode, "on");
broker.deliver("announcements/mode", "off");
const afterMqtt = (await jsonRequest(baseUrl, "/api/playback")).body;
assert.equal(afterMqtt.mode, "off", "MQTT committed through the playback service");
assert.equal(afterMqtt.playbackRevision, start.playbackRevision + 1);
await jsonRequest(baseUrl, "/api/playback/mode", { method: "PUT", body: { mode: "on" } });
const afterRest = (await jsonRequest(baseUrl, "/api/playback")).body;
assert.equal(afterRest.mode, "on");
// Both transports move the same cursor with identical semantics.
const beforeNext = (await jsonRequest(baseUrl, "/api/playback")).body;
broker.deliver("announcements/next", "");
const mqttNext = (await jsonRequest(baseUrl, "/api/playback")).body;
assert.notEqual(mqttNext.currentEntryId, beforeNext.currentEntryId);
const restNext = (await jsonRequest(baseUrl, "/api/playback/next", { method: "POST" })).body;
assert.equal(restNext.currentEntryId, beforeNext.currentEntryId, "next wrapped back");
broker.deliver("announcements/pause", "");
assert.equal((await jsonRequest(baseUrl, "/api/playback")).body.transportState, "paused");
broker.deliver("announcements/resume", "");
assert.equal((await jsonRequest(baseUrl, "/api/playback")).body.transportState, "playing");
broker.deliver("announcements/static", "asset-one");
assert.equal(
(await jsonRequest(baseUrl, "/api/playback")).body.transportState,
"playing",
"a non-UUID static payload is rejected before it reaches playback",
);
});
test("MQTT publishes retained authoritative state only after a committed change", async (t) => {
const broker = createFakeBroker();
const { runtime, baseUrl } = await startRuntime(t, { broker });
await seedPlaylist(runtime, baseUrl);
assert.equal(
broker.published.find((message) => message.topic === "announcements/availability").payload,
"online",
);
broker.published.length = 0;
await jsonRequest(baseUrl, "/api/playback/mode", { method: "PUT", body: { mode: "off" } });
assert.equal(
broker.published.filter((message) => message.topic === "announcements/mode/state").at(-1)
.payload,
"off",
);
// A read and a no-op command commit nothing, so nothing is republished.
broker.published.length = 0;
await jsonRequest(baseUrl, "/api/playback");
await jsonRequest(baseUrl, "/api/playback/mode", { method: "PUT", body: { mode: "off" } });
assert.equal(broker.published.length, 0);
const commandTopics = new Set(runtime.mqttService.commandTopics);
assert.ok(
broker.published.every((message) => !commandTopics.has(message.topic)),
"state is never echoed onto a command topic",
);
});
test("a broker outage degrades status while playback and HTTP continue", async (t) => {
const broker = createFakeBroker();
const { runtime, baseUrl } = await startRuntime(t, { broker });
await seedPlaylist(runtime, baseUrl);
broker.emit("error", new Error("connect ECONNREFUSED"));
broker.emit("close");
const health = await jsonRequest(baseUrl, "/api/health");
assert.equal(health.response.status, 200, "MQTT is never readiness-critical");
assert.equal(health.body.services.mqtt.status, "disconnected");
assert.equal(health.body.status, "degraded");
assert.equal((await jsonRequest(baseUrl, "/health/ready")).response.status, 200);
// Playback keeps working through REST while the broker is unreachable.
const advanced = await jsonRequest(baseUrl, "/api/playback/next", { method: "POST" });
assert.equal(advanced.response.status, 200);
assert.ok(advanced.body.currentEntryId);
assert.equal(runtime.playbackService.getSnapshot().transportState, "playing");
});
test("an MQTT publish failure after commit never rolls back playback", async (t) => {
const broker = createFakeBroker({ failPublish: true });
const { runtime, baseUrl } = await startRuntime(t, { broker });
await seedPlaylist(runtime, baseUrl);
const before = (await jsonRequest(baseUrl, "/api/playback")).body;
const changed = await jsonRequest(baseUrl, "/api/playback/mode", {
method: "PUT",
body: { mode: "off" },
});
assert.equal(changed.response.status, 200);
assert.equal(changed.body.mode, "off");
assert.equal(changed.body.playbackRevision, before.playbackRevision + 1);
assert.equal(
runtime.database
.prepare("SELECT mode FROM playback_state WHERE output_id = 'global'")
.get().mode,
"off",
"the SQLite commit is authoritative regardless of MQTT",
);
});
test("an unreachable Home Assistant never blocks playback or the API", async (t) => {
const broker = createFakeBroker();
const { runtime, baseUrl } = await startRuntime(t, {
broker,
notificationFetch: () => Promise.reject(new Error("ECONNREFUSED")),
storage: testStorage({
status: "degraded",
code: "STORAGE_UNAVAILABLE",
checkedAt: null,
lastSuccessAt: null,
}),
});
await seedPlaylist(runtime, baseUrl);
runtime.notifications.observeFailure("storage", {
event: "storage.unavailable",
severity: "critical",
message: "Announcement storage is unavailable.",
context: {},
});
await runtime.notifications.deliverDue();
assert.equal(
runtime.database
.prepare("SELECT COUNT(*) AS count FROM notification_outbox WHERE delivered_at IS NULL")
.get().count,
1,
"the failed delivery stays pending for retry",
);
const playback = await jsonRequest(baseUrl, "/api/playback/next", { method: "POST" });
assert.equal(playback.response.status, 200);
assert.equal((await jsonRequest(baseUrl, "/health/ready")).response.status, 200);
});
test("display heartbeats flow over Socket.IO into the status API without touching playback", async (t) => {
const broker = createFakeBroker();
const { runtime, baseUrl } = await startRuntime(t, { broker });
await seedPlaylist(runtime, baseUrl);
const socket = await connectSocket(baseUrl);
t.after(() => socket.close().catch(() => {}));
const before = (await jsonRequest(baseUrl, "/api/playback")).body;
const playlistBefore = (await jsonRequest(baseUrl, "/api/playlists")).body;
await socket.send("display:heartbeat", {
protocolVersion: 1,
clientId: "lobby-display",
outputId: "global",
activePlaylistId: before.activePlaylistId,
playlistRevision: 1,
playbackRevision: before.playbackRevision,
currentEntryId: before.currentEntryId,
currentAssetId: "asset-one",
cachedAssetCount: 2,
playableAssetCount: 2,
mostRecentError: null,
});
await socket.send("display:heartbeat", { protocolVersion: 99, clientId: "bogus" });
await socket.send("display:heartbeat", "not-an-object");
await new Promise((resolve) => setTimeout(resolve, 100));
const displays = await jsonRequest(baseUrl, "/api/displays");
assert.equal(displays.response.status, 200);
assert.equal(displays.body.displays.length, 1, "malformed telemetry created no display");
const display = displays.body.displays[0];
assert.equal(display.clientId, "lobby-display");
assert.equal(display.status, "online");
assert.equal(display.cachedAssetCount, 2);
assert.equal(display.mostRecentError, null);
assert.equal(
(await jsonRequest(baseUrl, "/api/playback")).body.playbackRevision,
before.playbackRevision,
"telemetry never increments the playback revision",
);
assert.deepEqual(
(await jsonRequest(baseUrl, "/api/playlists")).body,
playlistBefore,
"telemetry never increments a playlist revision",
);
const health = await jsonRequest(baseUrl, "/api/health");
assert.equal(health.body.services.displays.known, 1);
assert.equal(health.body.services.displays.online, 1);
});
test("a first display heartbeat is announced once as a display:status event", async (t) => {
const broker = createFakeBroker();
const { runtime, baseUrl } = await startRuntime(t, { broker });
await seedPlaylist(runtime, baseUrl);
const socket = await connectSocket(baseUrl);
t.after(() => socket.close().catch(() => {}));
const beat = {
protocolVersion: 1,
clientId: "narthex-display",
outputId: "global",
cachedAssetCount: 1,
playableAssetCount: 1,
};
await socket.send("display:heartbeat", beat);
await socket.send("display:heartbeat", beat);
await socket.send("display:heartbeat", beat);
await new Promise((resolve) => setTimeout(resolve, 100));
const events = socketEvents(await socket.poll()).filter(
([name]) => name === "display:status",
);
assert.equal(events.length, 1, "steady telemetry does not create realtime churn");
assert.equal(events[0][1].reason, "first_seen");
assert.equal(events[0][1].clientId, "narthex-display");
assert.doesNotMatch(JSON.stringify(events[0][1]), /socket|sid/i);
});
test("detailed status reports every integration without leaking any secret", async (t) => {
const broker = createFakeBroker();
const { baseUrl } = await startRuntime(t, { broker });
const health = await jsonRequest(baseUrl, "/api/health");
const status = await jsonRequest(baseUrl, "/api/status");
assert.equal(health.body.services.mqtt.status, "connected");
assert.equal(health.body.services.mqtt.configured, true);
assert.equal(health.body.services.mqtt.topicPrefix, "announcements");
assert.equal(health.body.services.notifications.configured, true);
assert.equal(health.body.services.notifications.status, "ok");
assert.equal(health.body.services.displays.status, "none_seen");
assert.equal(health.body.services.playback.status, "ok");
assert.equal(status.response.status, 200);
const serialized = JSON.stringify(health.body) + JSON.stringify(status.body);
assert.doesNotMatch(serialized, /mqtt-password-that-must-not-leak/);
assert.doesNotMatch(serialized, /home-assistant:8123/);
assert.doesNotMatch(serialized, /private-id/);
assert.doesNotMatch(serialized, /webhook.?url/i);
assert.doesNotMatch(serialized, /broker:1883/);
});
+250
View File
@@ -0,0 +1,250 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const mqtt = require("mqtt");
const { loadConfig } = require("../src/config");
const { createRuntime } = require("../src/runtime");
// Protocol behaviour that a hand-written fake cannot prove — retained
// messages, subscription delivery, and a broker-published last will — is
// verified against a real broker. The isolated verification stack sets
// MQTT_TEST_URL; without it these checks are skipped rather than faked.
const BROKER_URL = process.env.MQTT_TEST_URL;
const silentLogger = { info() {}, warn() {}, error() {} };
function testConfig(overrides = {}) {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
SHUTDOWN_TIMEOUT_MS: "1000",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_BUCKET: "announcements-test",
MQTT_URL: BROKER_URL,
...overrides,
});
}
function testStorage() {
return {
async start() {},
stop() {},
health: () => ({ status: "healthy", checkedAt: null, lastSuccessAt: null }),
};
}
function connectObserver() {
return new Promise((resolve, reject) => {
const client = mqtt.connect(BROKER_URL, { reconnectPeriod: 0 });
client.once("connect", () => resolve(client));
client.once("error", reject);
});
}
function collect(client, topic) {
const received = [];
client.on("message", (messageTopic, payload) => {
if (messageTopic === topic) received.push(payload.toString("utf8"));
});
return received;
}
function waitFor(predicate, { timeoutMs = 5_000, label = "condition" } = {}) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs;
const tick = () => {
let value;
try {
value = predicate();
} catch (error) {
reject(error);
return;
}
if (value) {
resolve(value);
return;
}
if (Date.now() > deadline) {
reject(new Error(`Timed out waiting for ${label}`));
return;
}
setTimeout(tick, 25);
};
tick();
});
}
async function subscribe(client, topic, options = {}) {
await new Promise((resolve, reject) => {
client.subscribe(topic, { qos: 1, ...options }, (error) =>
error ? reject(error) : resolve(),
);
});
}
async function publish(client, topic, payload, options = {}) {
await new Promise((resolve, reject) => {
client.publish(topic, payload, { qos: 1, ...options }, (error) =>
error ? reject(error) : resolve(),
);
});
}
async function startRuntime(t) {
const runtime = createRuntime({
config: testConfig(),
logger: silentLogger,
storage: testStorage(),
});
await runtime.start();
t.after(async () => {
try {
await runtime.stop("test cleanup");
} catch {
// A test that already stopped the runtime is fine.
}
});
await waitFor(() => runtime.mqttService.status().connected, {
label: "the API to connect to the broker",
});
return { runtime, baseUrl: `http://127.0.0.1:${runtime.address.port}` };
}
async function seedPlayback(runtime, baseUrl) {
const timestamp = Date.now();
for (const [id, hash] of [
["asset-one", "a"],
["asset-two", "c"],
]) {
runtime.database
.prepare(
`INSERT INTO assets (
id, source_sha256, display_name, original_filename, status,
display_object_key, display_sha256, display_content_type,
display_byte_size, display_width, display_height, created_at, updated_at
) VALUES (?, ?, ?, ?, 'ready', 'private/display/x.webp', ?, 'image/webp', 10, 1920, 1080, ?, ?)`,
)
.run(id, hash.repeat(64), id, `${id}.png`, hash.repeat(64), timestamp, timestamp);
}
const created = await fetch(`${baseUrl}/api/playlists`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Broker" }),
});
const playlist = await created.json();
for (const assetId of ["asset-one", "asset-two"]) {
await fetch(`${baseUrl}/api/playlists/${playlist.id}/entries`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ assetId, durationOverrideMs: 60_000 }),
});
}
await fetch(`${baseUrl}/api/playlists/${playlist.id}/activate`, { method: "POST" });
return playlist;
}
const options = BROKER_URL ? {} : { skip: "MQTT_TEST_URL is not configured" };
test("a real broker delivers commands and retains authoritative state", options, async (t) => {
const { runtime, baseUrl } = await startRuntime(t);
await seedPlayback(runtime, baseUrl);
const observer = await connectObserver();
t.after(() => observer.endAsync?.() ?? observer.end(true));
const availability = collect(observer, "announcements/availability");
const modeState = collect(observer, "announcements/mode/state");
await subscribe(observer, "announcements/availability");
await subscribe(observer, "announcements/mode/state");
// A late subscriber receives retained state immediately, which is what a
// Home Assistant restart depends on.
await waitFor(() => availability.includes("online"), {
label: "retained availability",
});
await waitFor(() => modeState.length > 0, { label: "retained mode state" });
assert.equal(modeState.at(-1), "on");
const commander = await connectObserver();
t.after(() => commander.endAsync?.() ?? commander.end(true));
await publish(commander, "announcements/mode", "off");
await waitFor(
() =>
runtime.database
.prepare("SELECT mode FROM playback_state WHERE output_id = 'global'")
.get().mode === "off",
{ label: "the mode command to commit" },
);
await waitFor(() => modeState.at(-1) === "off", { label: "retained mode state to follow" });
const before = runtime.playbackService.getSnapshot();
await publish(commander, "announcements/next", "", { qos: 0 });
await waitFor(
() => runtime.playbackService.getSnapshot().currentEntryId !== before.currentEntryId,
{ label: "the next command to advance playback" },
);
await publish(commander, "announcements/pause", "");
await waitFor(() => runtime.playbackService.getSnapshot().transportState === "paused", {
label: "pause",
});
await publish(commander, "announcements/resume", "");
await waitFor(() => runtime.playbackService.getSnapshot().transportState === "playing", {
label: "resume",
});
// An invalid payload is ignored over the real broker exactly as it is
// locally: the last committed mode survives untouched.
await publish(commander, "announcements/mode", " ON ");
await waitFor(() => runtime.playbackService.getSnapshot().mode === "on", {
label: "a whitespace-padded mode command to commit",
});
await publish(commander, "announcements/mode", "dim");
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(runtime.playbackService.getSnapshot().mode, "on");
assert.equal(modeState.at(-1), "on", "no fake state was published for a rejected command");
});
test("a clean API shutdown retracts its retained availability", options, async (t) => {
const { runtime } = await startRuntime(t);
const observer = await connectObserver();
t.after(() => observer.endAsync?.() ?? observer.end(true));
const availability = collect(observer, "announcements/availability");
await subscribe(observer, "announcements/availability");
await waitFor(() => availability.includes("online"), { label: "online availability" });
await runtime.stop("clean shutdown test");
await waitFor(() => availability.at(-1) === "offline", {
label: "availability to be retracted on a planned shutdown",
});
});
test("an ungraceful disappearance makes the broker publish the last will", options, async (t) => {
const observer = await connectObserver();
t.after(() => observer.endAsync?.() ?? observer.end(true));
const availability = collect(observer, "test/availability");
await subscribe(observer, "test/availability");
// A crashed or power-cut API never sends DISCONNECT. Only a will can report
// that, so the will configuration is exercised against a real broker here;
// the unit suite proves the service registers exactly these options.
const willed = mqtt.connect(BROKER_URL, {
reconnectPeriod: 0,
will: { topic: "test/availability", payload: "offline", qos: 1, retain: true },
});
await new Promise((resolve, reject) => {
willed.once("connect", resolve);
willed.once("error", reject);
});
await publish(willed, "test/availability", "online", { retain: true });
await waitFor(() => availability.includes("online"), { label: "online availability" });
willed.stream.destroy();
await waitFor(() => availability.at(-1) === "offline", {
label: "the broker to publish the last will",
timeoutMs: 10_000,
});
});
+403
View File
@@ -0,0 +1,403 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { COMMANDS, createMqttService } = require("../src/mqtt");
const PREFIX = "announcements";
function createFakeClient() {
const listeners = new Map();
const client = {
published: [],
subscriptions: [],
unsubscribed: [],
ended: false,
options: null,
on(event, handler) {
listeners.set(event, [...(listeners.get(event) || []), handler]);
return client;
},
emit(event, ...args) {
for (const handler of listeners.get(event) || []) handler(...args);
},
publish(topic, payload, options, callback) {
client.published.push({ topic, payload: String(payload), options });
callback?.(null);
},
subscribe(topic, options, callback) {
client.subscriptions.push({ topic, qos: options.qos });
callback?.(null);
},
unsubscribe(topic) {
client.unsubscribed.push(topic);
},
end(force, options, callback) {
client.ended = true;
callback?.();
},
};
return client;
}
function createPlaybackStub(snapshot = {}) {
const calls = [];
const base = {
mode: "on",
transportState: "playing",
transport: { rotationPaused: false },
activePlaylistId: "playlist-a",
currentEntryId: "entry-a",
normalTarget: { assetId: "asset-a" },
staticTarget: null,
playbackRevision: 7,
...snapshot,
};
return {
calls,
getSnapshot: () => base,
setMode(mode) {
calls.push(["setMode", mode]);
},
next() {
calls.push(["next"]);
},
previous() {
calls.push(["previous"]);
},
pause() {
calls.push(["pause"]);
},
resume() {
calls.push(["resume"]);
},
reload() {
calls.push(["reload"]);
},
enterStatic(assetId) {
calls.push(["enterStatic", assetId]);
if (assetId === "11111111-1111-4111-8111-111111111111") {
const error = new Error("Library asset not found");
error.code = "ASSET_NOT_FOUND";
throw error;
}
if (assetId === "22222222-2222-4222-8222-222222222222") {
const error = new Error("Static playback requires a ready library asset");
error.code = "ASSET_NOT_READY";
throw error;
}
if (assetId === "33333333-3333-4333-8333-333333333333") {
const error = new Error("Expired library assets cannot be held statically");
error.code = "ASSET_EXPIRED";
throw error;
}
},
};
}
function createHarness({ snapshot, url = "mqtt://broker:1883" } = {}) {
const client = createFakeClient();
const playbackService = createPlaybackStub(snapshot);
const service = createMqttService({
config: {
mqtt: {
url,
username: "user",
password: "must-not-leak",
topicPrefix: PREFIX,
reconnectPeriodMs: 5_000,
connectTimeoutMs: 10_000,
},
},
playbackService,
logger: { info() {}, warn() {} },
connect: (connectUrl, options) => {
client.options = options;
client.url = connectUrl;
return client;
},
});
return { client, playbackService, service };
}
function connectHarness(options) {
const harness = createHarness(options);
harness.service.start();
harness.client.emit("connect");
return harness;
}
test("the default configuration resolves the exact required command topics", () => {
const harness = createHarness();
assert.deepEqual(harness.service.commandTopics.sort(), [
"announcements/mode",
"announcements/next",
"announcements/pause",
"announcements/previous",
"announcements/reload",
"announcements/resume",
"announcements/static",
]);
assert.equal(harness.service.availabilityTopic, "announcements/availability");
assert.equal(harness.service.modeStateTopic, "announcements/mode/state");
assert.equal(harness.service.playbackStateTopic, "announcements/playback/state");
});
test("connecting configures a retained last will and publishes availability", () => {
const harness = connectHarness();
assert.deepEqual(harness.client.options.will, {
topic: "announcements/availability",
payload: "offline",
qos: 1,
retain: true,
});
assert.equal(harness.client.options.reconnectPeriod, 5_000);
const availability = harness.client.published.find(
(message) => message.topic === "announcements/availability",
);
assert.equal(availability.payload, "online");
assert.equal(availability.options.retain, true);
assert.equal(availability.options.qos, 1);
});
test("every required command topic is subscribed with its delivery guarantee", () => {
const harness = connectHarness();
const byTopic = Object.fromEntries(
harness.client.subscriptions.map((entry) => [entry.topic, entry.qos]),
);
assert.equal(byTopic["announcements/mode"], 1);
assert.equal(byTopic["announcements/pause"], 1);
assert.equal(byTopic["announcements/resume"], 1);
assert.equal(byTopic["announcements/reload"], 1);
assert.equal(byTopic["announcements/static"], 1);
// Redelivering a non-idempotent edge command would silently skip a slide.
assert.equal(byTopic["announcements/next"], 0);
assert.equal(byTopic["announcements/previous"], 0);
assert.equal(harness.client.subscriptions.length, COMMANDS.length);
});
test("mode commands delegate to the playback service and tolerate whitespace and case", () => {
const harness = connectHarness();
harness.client.emit("message", "announcements/mode", Buffer.from("off"));
harness.client.emit("message", "announcements/mode", Buffer.from(" ON \n"));
harness.client.emit("message", "announcements/mode", Buffer.from("OFF"));
assert.deepEqual(harness.playbackService.calls, [
["setMode", "off"],
["setMode", "on"],
["setMode", "off"],
]);
});
test("an invalid mode payload changes nothing and publishes no fake state", () => {
const harness = connectHarness();
const publishedBefore = harness.client.published.length;
for (const payload of ["dim", "", " ", "1", "{\"mode\":\"on\"}"]) {
harness.client.emit("message", "announcements/mode", Buffer.from(payload));
}
assert.deepEqual(harness.playbackService.calls, []);
assert.equal(harness.client.published.length, publishedBefore);
assert.equal(harness.service.status().commandCount, 0);
});
test("edge commands invoke the exact playback operation regardless of payload", () => {
const harness = connectHarness();
harness.client.emit("message", "announcements/next", Buffer.from(""));
harness.client.emit("message", "announcements/previous", Buffer.from(" "));
harness.client.emit("message", "announcements/pause", Buffer.from("press"));
harness.client.emit("message", "announcements/resume", Buffer.from("\n"));
harness.client.emit("message", "announcements/reload", null);
assert.deepEqual(harness.playbackService.calls, [
["next"],
["previous"],
["pause"],
["resume"],
["reload"],
]);
});
test("static accepts a LibraryAsset UUID and rejects anything that is not one", () => {
const harness = connectHarness();
const valid = "44444444-4444-4444-8444-444444444444";
harness.client.emit("message", "announcements/static", Buffer.from(` ${valid} `));
for (const payload of [
"not-a-uuid",
"https://example.com/image.png",
"private/display/asset.webp",
"bulletin.png",
"../../etc/passwd",
"",
]) {
harness.client.emit("message", "announcements/static", Buffer.from(payload));
}
assert.deepEqual(
harness.playbackService.calls,
[["enterStatic", valid]],
"only a stable asset UUID may reach the playback service",
);
});
test("a static command the playback service rejects leaves playback untouched", () => {
const harness = connectHarness();
const publishedBefore = harness.client.published.length;
for (const assetId of [
"11111111-1111-4111-8111-111111111111",
"22222222-2222-4222-8222-222222222222",
"33333333-3333-4333-8333-333333333333",
]) {
harness.client.emit("message", "announcements/static", Buffer.from(assetId));
}
// The rejection is logged; nothing is published and the output never goes
// black merely because an operator sent a bad asset ID.
assert.equal(harness.client.published.length, publishedBefore);
assert.equal(harness.service.status().connected, true);
});
test("authoritative state is retained on state topics and never echoed to commands", () => {
const harness = connectHarness();
harness.service.publishState({
mode: "off",
transport: { rotationPaused: true },
activePlaylistId: "playlist-b",
currentEntryId: "entry-b",
normalTarget: { assetId: "asset-b" },
staticTarget: null,
playbackRevision: 12,
});
const modeState = harness.client.published.filter(
(message) => message.topic === "announcements/mode/state",
);
assert.equal(modeState.at(-1).payload, "off");
assert.equal(modeState.at(-1).options.retain, true);
assert.equal(modeState.at(-1).options.qos, 1);
const compact = JSON.parse(
harness.client.published
.filter((message) => message.topic === "announcements/playback/state")
.at(-1).payload,
);
assert.deepEqual(compact, {
version: 1,
mode: "off",
transport: "paused",
activePlaylistId: "playlist-b",
currentEntryId: "entry-b",
currentAssetId: "asset-b",
staticAssetId: null,
revision: 12,
});
const commandTopics = new Set(harness.service.commandTopics);
assert.ok(
harness.client.published.every((message) => !commandTopics.has(message.topic)),
"the server must never publish onto a command topic",
);
});
test("the compact state topic never carries the manifest or private storage details", () => {
const harness = connectHarness();
harness.service.publishState({
mode: "on",
transport: { rotationPaused: false },
activePlaylistId: "playlist-a",
currentEntryId: "entry-a",
normalTarget: {
assetId: "asset-a",
asset: { media: { url: "/media/assets/a/display/abc.webp" } },
},
staticTarget: null,
playbackRevision: 3,
manifest: { entries: [{ id: "entry-a" }, { id: "entry-b" }] },
});
const payload = harness.client.published
.filter((message) => message.topic === "announcements/playback/state")
.at(-1).payload;
assert.ok(payload.length < 400, "the compact topic stays small");
assert.doesNotMatch(payload, /manifest|media|object_key|bucket|private/i);
});
test("reconnecting resubscribes and republishes availability and authoritative state", () => {
const harness = connectHarness();
harness.client.published.length = 0;
harness.client.subscriptions.length = 0;
harness.client.emit("close");
assert.equal(harness.service.status().connected, false);
assert.equal(harness.service.status().status, "disconnected");
harness.client.emit("connect");
assert.equal(harness.client.subscriptions.length, COMMANDS.length);
assert.equal(
harness.client.published.find(
(message) => message.topic === "announcements/availability",
).payload,
"online",
);
assert.equal(
harness.client.published.find(
(message) => message.topic === "announcements/mode/state",
).payload,
"on",
);
assert.equal(harness.service.status().connectionCount, 2);
});
test("a broker error degrades the integration without throwing or leaking the password", () => {
const harness = connectHarness();
assert.doesNotThrow(() =>
harness.client.emit("error", new Error("connect ECONNREFUSED 10.0.0.5:1883")),
);
const status = harness.service.status();
assert.equal(status.connected, false);
assert.equal(status.status, "disconnected");
assert.match(status.lastErrorMessage, /ECONNREFUSED/);
assert.doesNotMatch(JSON.stringify(status), /must-not-leak/);
});
test("an unconfigured broker leaves the integration disabled and inert", async () => {
const harness = createHarness({ url: null });
assert.equal(harness.service.start(), null);
assert.equal(harness.service.status().status, "disabled");
assert.equal(harness.service.status().configured, false);
assert.doesNotThrow(() => harness.service.publishState({ mode: "on" }));
await harness.service.stop();
assert.equal(harness.client.published.length, 0);
});
test("messages on unknown topics are ignored without crashing", () => {
const harness = connectHarness();
assert.doesNotThrow(() => {
harness.client.emit("message", "announcements/unknown", Buffer.from("x"));
harness.client.emit("message", "other/mode", Buffer.from("on"));
});
assert.deepEqual(harness.playbackService.calls, []);
});
test("a clean shutdown retracts availability and unsubscribes every command", async () => {
const harness = connectHarness();
await harness.service.stop();
const availability = harness.client.published
.filter((message) => message.topic === "announcements/availability")
.at(-1);
assert.equal(availability.payload, "offline");
assert.equal(availability.options.retain, true);
assert.equal(harness.client.unsubscribed.length, COMMANDS.length);
assert.equal(harness.client.ended, true);
});
+383
View File
@@ -0,0 +1,383 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const {
RETRY_BACKOFF_MS,
backoffMs,
createNotificationService,
} = require("../src/notifications");
const {
observeAssetProcessingFailure,
observePlayback,
observeStorageHealth,
} = require("../src/notifications/conditions");
const BASE_TIME = 2_000_000_000_000;
const WEBHOOK = "http://127.0.0.1:9/announcements";
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;
},
pending() {
return [...timers].filter((timer) => !timer.cleared && !timer.fired);
},
async runTo(value) {
current = value;
for (let guard = 0; guard < 100; guard += 1) {
const timer = this.pending()
.filter((candidate) => candidate.at <= current)
.sort((left, right) => left.at - right.at || left.id - right.id)[0];
if (!timer) return;
timer.fired = true;
await timer.callback();
}
throw new Error("timer loop did not settle");
},
};
}
function createHarness({ webhookUrl = WEBHOOK, responses = [] } = {}) {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
const clock = createClock();
const calls = [];
let idCounter = 0;
function fetchFn(url, init) {
calls.push({ url, body: JSON.parse(init.body), headers: init.headers });
const next = responses.shift();
if (typeof next === "function") return next();
if (next instanceof Error) return Promise.reject(next);
return Promise.resolve({ status: next ?? 200 });
}
function build() {
return createNotificationService({
database,
webhookUrl,
webhookSecret: "super-secret-webhook-id",
now: clock.now,
setTimeoutFn: clock.setTimeout,
clearTimeoutFn: clock.clearTimeout,
createId: () => `note-${++idCounter}`,
fetchFn,
logger: { warn() {} },
});
}
return {
build,
calls,
clock,
database,
responses,
outbox: () =>
database.prepare("SELECT * FROM notification_outbox ORDER BY created_at, id").all(),
incidents: () =>
database
.prepare("SELECT * FROM notification_incidents ORDER BY opened_at, episode")
.all(),
};
}
const STORAGE_FAILURE = {
event: "storage.unavailable",
severity: "critical",
message: "Announcement storage is unavailable.",
context: { checkedAt: "now" },
};
test("a persistent failure opens one incident and queues exactly one notification", async (t) => {
const harness = createHarness();
const service = harness.build();
t.after(() => harness.database.close());
service.start();
const first = service.observeFailure("storage", STORAGE_FAILURE);
assert.equal(first.opened, true);
// A long outage is observed many times; none of it may become new work.
for (let observation = 0; observation < 25; observation += 1) {
harness.clock.set(BASE_TIME + observation * 60_000);
assert.equal(service.observeFailure("storage", STORAGE_FAILURE).opened, false);
}
assert.equal(harness.incidents().length, 1);
assert.equal(harness.outbox().length, 1);
assert.equal(harness.incidents()[0].state, "open");
assert.equal(
harness.incidents()[0].last_observed_at,
BASE_TIME + 24 * 60_000,
"a continuing failure only updates its observation time",
);
});
test("recovery queues exactly one recovery and a later failure is a new episode", async (t) => {
const harness = createHarness();
const service = harness.build();
t.after(() => harness.database.close());
service.start();
service.observeFailure("storage", STORAGE_FAILURE);
harness.clock.set(BASE_TIME + 30 * 60_000);
const recovery = service.observeRecovery("storage", {
event: "storage.recovered",
message: "Announcement storage is reachable again.",
});
assert.equal(recovery.recovered, true);
assert.equal(service.observeRecovery("storage", {}).recovered, false);
assert.equal(harness.outbox().length, 2);
harness.clock.set(BASE_TIME + 60 * 60_000);
const second = service.observeFailure("storage", STORAGE_FAILURE);
assert.equal(second.opened, true);
const incidents = harness.incidents();
assert.equal(incidents.length, 2);
assert.deepEqual(
incidents.map((incident) => incident.episode),
[1, 2],
"a repeat condition is a new episode, not a reopened incident",
);
assert.equal(incidents[0].state, "recovered");
assert.equal(incidents[1].state, "open");
assert.equal(harness.outbox().length, 3);
});
test("a 2xx response marks delivery complete and carries the versioned payload", async (t) => {
const harness = createHarness({ responses: [204] });
const service = harness.build();
t.after(() => harness.database.close());
service.start();
service.observeFailure("storage", STORAGE_FAILURE);
await harness.clock.runTo(BASE_TIME);
assert.equal(harness.calls.length, 1);
const payload = harness.calls[0].body;
assert.equal(payload.version, 1);
assert.equal(payload.event, "storage.unavailable");
assert.equal(payload.severity, "critical");
assert.equal(payload.state, "failure");
assert.equal(payload.message, "Announcement storage is unavailable.");
assert.match(payload.timestamp, /^\d{4}-\d{2}-\d{2}T/);
assert.ok(payload.notificationId && payload.incidentId);
assert.equal(payload.context.conditionKey, "storage");
assert.equal(payload.context.episode, 1);
assert.equal(harness.outbox()[0].delivered_at, BASE_TIME);
assert.equal(service.status().pendingCount, 0);
assert.equal(service.status().deliveredCount, 1);
});
test("failed deliveries retry on a capped schedule with a stable notification id", async (t) => {
const harness = createHarness({
// One attempt per documented backoff step: transport errors and both 4xx
// and 5xx responses follow the same retry policy.
responses: [
new Error("ECONNREFUSED"),
500,
404,
new Error("ECONNREFUSED"),
new Error("ECONNREFUSED"),
200,
],
});
const service = harness.build();
t.after(() => harness.database.close());
service.start();
service.observeFailure("storage", STORAGE_FAILURE);
let elapsed = 0;
await harness.clock.runTo(BASE_TIME);
assert.equal(harness.calls.length, 1);
// Documented schedule: immediate, then 30s, 1m, 5m, 15m, 30m, capped at 30m.
for (const expected of RETRY_BACKOFF_MS) {
assert.equal(
harness.outbox()[0].next_attempt_at,
BASE_TIME + elapsed + expected,
`retry must be scheduled ${expected}ms after the failed attempt`,
);
elapsed += expected;
await harness.clock.runTo(BASE_TIME + elapsed);
}
assert.equal(backoffMs(50), 1_800_000, "backoff is capped, not unbounded");
const ids = new Set(harness.calls.map((call) => call.body.notificationId));
assert.equal(ids.size, 1, "retries must reuse one stable delivery identity");
assert.equal(harness.outbox()[0].delivered_at, BASE_TIME + elapsed);
assert.equal(harness.outbox().length, 1, "retrying never creates new rows");
});
test("open incidents and pending deliveries survive an API restart", async (t) => {
const harness = createHarness({ responses: [new Error("ECONNREFUSED")] });
const first = harness.build();
t.after(() => harness.database.close());
first.start();
first.observeFailure("storage", STORAGE_FAILURE);
await harness.clock.runTo(BASE_TIME);
assert.equal(harness.outbox()[0].delivered_at, null);
first.stop();
// A restart re-reads durable state; it must not re-notify or lose work.
harness.clock.set(BASE_TIME + 5 * 60_000);
const restarted = harness.build();
restarted.start();
assert.equal(restarted.isOpen("storage"), true);
assert.equal(
restarted.observeFailure("storage", STORAGE_FAILURE).opened,
false,
"a restart must not reopen an already-open incident",
);
assert.equal(harness.outbox().length, 1);
assert.equal(restarted.status().pendingCount, 1);
harness.responses.push(200);
await harness.clock.runTo(BASE_TIME + 10 * 60_000);
assert.equal(harness.outbox()[0].delivered_at, BASE_TIME + 10 * 60_000);
assert.equal(harness.calls.at(-1).body.notificationId, harness.calls[0].body.notificationId);
});
test("an unconfigured webhook records incidents without queuing endless delivery work", async (t) => {
const harness = createHarness({ webhookUrl: null });
const service = harness.build();
t.after(() => harness.database.close());
service.start();
service.observeFailure("storage", STORAGE_FAILURE);
service.observeRecovery("storage", { event: "storage.recovered" });
await harness.clock.runTo(BASE_TIME + 60 * 60_000);
assert.equal(harness.incidents().length, 1, "incident history stays useful");
assert.equal(harness.outbox().length, 0, "nothing retries against a receiver that does not exist");
assert.equal(harness.calls.length, 0);
const status = service.status();
assert.equal(status.configured, false);
assert.equal(status.status, "disabled");
});
test("notification status never reveals the webhook URL or secret", async (t) => {
const harness = createHarness({ responses: [new Error("ECONNREFUSED")] });
const service = harness.build();
t.after(() => harness.database.close());
service.start();
service.observeFailure("storage", STORAGE_FAILURE);
await harness.clock.runTo(BASE_TIME);
const serialized = JSON.stringify(service.status());
assert.doesNotMatch(serialized, /super-secret-webhook-id/);
assert.doesNotMatch(serialized, /127\.0\.0\.1:9/);
assert.doesNotMatch(serialized, /announcements\/?"?$/);
assert.equal(service.status().configured, true);
assert.equal(service.status().openIncidents.length, 1);
assert.equal(service.status().openIncidents[0].conditionKey, "storage");
});
test("storage health transitions drive exactly one incident pair", async (t) => {
const harness = createHarness();
const service = harness.build();
t.after(() => harness.database.close());
service.start();
observeStorageHealth(service, {
status: "degraded",
code: "STORAGE_NOT_CHECKED",
});
assert.equal(harness.incidents().length, 0, "startup state is not an outage");
for (let observation = 0; observation < 5; observation += 1) {
observeStorageHealth(service, {
status: "degraded",
code: "STORAGE_UNAVAILABLE",
checkedAt: "2026-08-16T12:00:00.000Z",
});
}
assert.equal(harness.outbox().length, 1);
observeStorageHealth(service, { status: "healthy", checkedAt: "2026-08-16T12:30:00.000Z" });
assert.equal(harness.outbox().length, 2);
assert.equal(harness.outbox()[1].transition, "recovery");
});
test("no playable slides is reported even while a static override fills the screen", async (t) => {
const harness = createHarness();
const service = harness.build();
t.after(() => harness.database.close());
service.start();
observePlayback(service, {
activePlaylistId: "playlist-a",
normalTarget: { id: "entry" },
mode: "on",
});
assert.equal(harness.incidents().length, 0);
observePlayback(service, {
activePlaylistId: "playlist-a",
normalTarget: null,
staticTarget: { assetId: "static-asset" },
mode: "on",
});
const open = harness.incidents();
assert.equal(open.length, 1);
assert.equal(open[0].event, "playback.no_playable_slides");
assert.equal(JSON.parse(open[0].context_json).staticActive, true);
observePlayback(service, {
activePlaylistId: "playlist-a",
normalTarget: null,
staticTarget: { assetId: "static-asset" },
mode: "on",
});
assert.equal(harness.outbox().length, 1, "a continuing condition never re-notifies");
observePlayback(service, {
activePlaylistId: "playlist-a",
normalTarget: { id: "entry" },
mode: "on",
});
assert.equal(harness.incidents()[0].state, "recovered");
assert.equal(harness.outbox().length, 2);
});
test("each failed upload opens one bounded warning incident", async (t) => {
const harness = createHarness();
const service = harness.build();
t.after(() => harness.database.close());
service.start();
observeAssetProcessingFailure(service, {
assetId: "asset-1",
displayName: "Bulletin",
message: "x".repeat(5_000),
});
observeAssetProcessingFailure(service, {
assetId: "asset-1",
displayName: "Bulletin",
message: "again",
});
observeAssetProcessingFailure(service, { assetId: "asset-2", displayName: "Notice" });
assert.equal(harness.incidents().length, 2, "one incident per failed asset");
assert.equal(harness.outbox().length, 2);
const context = JSON.parse(harness.incidents()[0].context_json);
assert.ok(
context.processingError.length <= 500,
"a processing error is bounded before it is stored",
);
assert.equal(harness.incidents()[0].severity, "warning");
});
+288
View File
@@ -0,0 +1,288 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const { createPlaybackService } = require("../src/playback/service");
// Client/server contract.
//
// The disconnected display necessarily reimplements part of the playback
// timeline. Rather than trusting that the two agree, every scenario below runs
// the real Phase 3B engine and the browser evaluator over identical data and
// asserts they land on the same slide, start, and deadline. Phase 3B is not
// refactored to share code; it stays the verified authority.
const CLIENT_MODULE = pathToFileURL(
path.join(__dirname, "..", "..", "client", "src", "display", "offlineTimeline.js"),
).href;
const BASE_TIME = 2_000_000_000_000;
function createHarness({ defaultDurationMs = 1_000 } = {}) {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
database
.prepare("UPDATE app_settings SET default_duration_ms = ?, updated_at = ? WHERE id = 1")
.run(defaultDurationMs, BASE_TIME);
let current = BASE_TIME;
const clock = {
now: () => current,
set(value) {
current = value;
},
};
let assetSequence = 0;
let service = null;
function seedAsset({ expirationAt = null } = {}) {
const ordinal = ++assetSequence;
const id = `asset-${ordinal}`;
const sha = ordinal.toString(16).padStart(64, "0");
database
.prepare(
`INSERT INTO assets (
id, source_sha256, display_name, original_filename, status, expiration_at,
display_object_key, display_sha256, display_content_type, display_byte_size,
display_width, display_height, created_at, updated_at
) VALUES (?, ?, ?, ?, 'ready', ?, ?, ?, 'image/webp', 10, 1920, 1080, ?, ?)`,
)
.run(id, sha, id, `${id}.png`, expirationAt, `private/${id}.webp`, sha, BASE_TIME, BASE_TIME);
return id;
}
function seedPlaylist(entries) {
database
.prepare(
"INSERT INTO playlists (id, name, revision, created_at, updated_at) VALUES ('p', 'P', 1, ?, ?)",
)
.run(BASE_TIME, BASE_TIME);
entries.forEach((options, position) => {
const assetId = seedAsset({ expirationAt: options.assetExpirationAt ?? null });
database
.prepare(
`INSERT INTO playlist_entries (
id, playlist_id, asset_id, enabled, duration_override_ms,
schedule_start_at, schedule_end_at, position, created_at, updated_at
) VALUES (?, 'p', ?, 1, ?, ?, ?, ?, ?, ?)`,
)
.run(
options.id,
assetId,
options.durationMs ?? null,
options.scheduleStartAt ?? null,
options.scheduleEndAt ?? null,
position,
BASE_TIME,
BASE_TIME,
);
});
return "p";
}
function startService() {
service = createPlaybackService({
database,
now: clock.now,
setTimeoutFn: () => null,
clearTimeoutFn: () => {},
logger: { warn() {} },
});
service.initialize();
return service;
}
function playback() {
return database.prepare("SELECT * FROM playback_state WHERE output_id = 'global'").get();
}
return {
clock,
close: () => database.close(),
database,
playback,
seedPlaylist,
startService,
get service() {
return service;
},
};
}
// Runs one disconnected scenario through both implementations.
async function compare(t, { entries, defaultDurationMs, offlineAt, label }) {
const { resolveOfflineState } = await import(CLIENT_MODULE);
const harness = createHarness({ defaultDurationMs });
t.after(() => harness.close());
harness.seedPlaylist(entries);
const service = harness.startService();
service.activatePlaylist("p");
// What the display would have persisted as its last authoritative state.
const snapshot = service.getSnapshot();
const anchor = {
entryId: snapshot.currentEntryId,
startedAt: snapshot.timeline.slideStartedAt,
deadlineAt: snapshot.timeline.nextSlideAt,
transportState: snapshot.transportState,
pausedRemainingMs: snapshot.timeline.pausedRemainingMs,
staticAssetId: snapshot.staticTarget?.assetId ?? null,
staticTarget: snapshot.staticTarget,
pendingPlaylistId: snapshot.pendingPlaylistId ?? null,
};
const manifest = snapshot.manifest;
// The server catches up the same outage on restart.
harness.clock.set(offlineAt);
harness.startService();
const server = harness.playback();
const client = resolveOfflineState({ manifest, anchor, at: offlineAt });
if (server.current_entry_id === null) {
assert.equal(client.kind, "black", `${label}: both must report no playable content`);
return { client, server };
}
assert.equal(client.kind, "normal", `${label}: client must resolve a normal slide`);
assert.equal(client.entryId, server.current_entry_id, `${label}: same slide`);
assert.equal(client.startedAt, server.slide_started_at, `${label}: same start instant`);
assert.equal(client.deadlineAt, server.next_slide_at, `${label}: same deadline`);
return { client, server };
}
test("offline catch-up matches the server across mixed durations and wraparound", async (t) => {
const entries = [
{ id: "a", durationMs: 3_000 },
{ id: "b", durationMs: 9_000 },
{ id: "c", durationMs: 4_000 },
];
// One cycle is 16s; each instant lands in a different part of the rotation.
for (const offset of [1_000, 3_000, 5_000, 12_000, 16_000, 17_000, 31_999, 32_000, 100_000]) {
await compare(t, {
entries,
offlineAt: BASE_TIME + offset,
label: `mixed durations +${offset}ms`,
});
}
});
test("offline catch-up matches the server over hours and days offline", async (t) => {
const entries = [
{ id: "a", durationMs: 3_000 },
{ id: "b", durationMs: 9_000 },
{ id: "c", durationMs: 4_000 },
];
const started = process.hrtime.bigint();
for (const offset of [
3_600_000, // one hour
86_400_000, // one day
604_800_000, // one week
2_592_000_000, // thirty days
]) {
await compare(t, {
entries,
offlineAt: BASE_TIME + offset + 1_234,
label: `long outage +${offset}ms`,
});
}
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
// Thirty days of 16-second cycles is ~160k slides; arithmetic catch-up must
// not step through them one at a time.
assert.ok(
elapsedMs < 4_000,
`catch-up must be arithmetic, not iterative (took ${elapsedMs.toFixed(0)}ms)`,
);
});
test("offline catch-up matches the server across a schedule start", async (t) => {
const entries = [
{ id: "always", durationMs: 1_000 },
{ id: "joins", durationMs: 1_000, scheduleStartAt: BASE_TIME + 20_000 },
];
for (const offset of [19_999, 20_000, 20_001, 25_000, 60_000]) {
await compare(t, {
entries,
offlineAt: BASE_TIME + offset,
label: `schedule start +${offset}ms`,
});
}
});
test("offline catch-up matches the server across a schedule end", async (t) => {
const entries = [
{ id: "ends", durationMs: 5_000, scheduleEndAt: BASE_TIME + 12_500 },
{ id: "stays", durationMs: 5_000 },
];
for (const offset of [12_499, 12_500, 12_501, 30_000, 120_000]) {
await compare(t, {
entries,
offlineAt: BASE_TIME + offset,
label: `schedule end +${offset}ms`,
});
}
});
test("offline catch-up matches the server across an asset expiration", async (t) => {
const entries = [
{ id: "expires", durationMs: 6_000, assetExpirationAt: BASE_TIME + 15_000 },
{ id: "permanent", durationMs: 4_000 },
];
for (const offset of [14_999, 15_000, 15_001, 40_000, 300_000]) {
await compare(t, {
entries,
offlineAt: BASE_TIME + offset,
label: `expiration +${offset}ms`,
});
}
});
test("offline evaluation agrees with the server when nothing is playable", async (t) => {
const entries = [{ id: "only", durationMs: 5_000, scheduleEndAt: BASE_TIME + 8_000 }];
const { client } = await compare(t, {
entries,
offlineAt: BASE_TIME + 30_000,
label: "no playable content",
});
assert.equal(client.kind, "black");
assert.equal(client.reason, "no_playable_content");
});
test("a permanent asset never expires locally no matter how long the display is offline", async (t) => {
const { resolveOfflineState } = await import(CLIENT_MODULE);
const manifest = {
entries: [
{
id: "permanent",
position: 0,
assetId: "asset-permanent",
effectiveDurationMs: 5_000,
scheduleStartAt: null,
scheduleEndAt: null,
// A null expiration is permanent and must stay permanent.
expirationAt: null,
},
],
};
const anchor = {
entryId: "permanent",
startedAt: BASE_TIME,
deadlineAt: BASE_TIME + 5_000,
transportState: "playing",
};
const afterAYear = resolveOfflineState({
manifest,
anchor,
at: BASE_TIME + 365 * 24 * 3_600_000,
});
assert.equal(afterAYear.kind, "normal");
assert.equal(afterAYear.entryId, "permanent");
assert.equal(
(afterAYear.deadlineAt - afterAYear.startedAt) % 5_000,
0,
"the slide keeps its configured duration on the original phase",
);
});
+11 -2
View File
@@ -30,8 +30,17 @@ function config() {
maxFiles: 2,
maxInputPixels: 1000,
},
mqtt: { url: null },
notifications: { homeAssistantWebhookUrl: null },
mqtt: {
url: null,
topicPrefix: "announcements",
reconnectPeriodMs: 5000,
connectTimeoutMs: 10000,
},
notifications: {
homeAssistantWebhookUrl: null,
webhookTimeoutMs: 10000,
},
displays: { offlineThresholdMs: 45000 },
};
}