Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423056e885 | ||
|
|
ba678d08d5 | ||
|
|
147e375702 | ||
|
|
ae70bf1dd8 | ||
|
|
993986c25e | ||
|
|
68273d457f | ||
|
|
e2ecdc97e2 | ||
|
|
722c6cf77d | ||
|
|
eb19675a5c |
@@ -0,0 +1,18 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/node_modules
|
||||||
|
.pnpm-store
|
||||||
|
**/dist
|
||||||
|
**/*.log
|
||||||
|
**/*.sqlite
|
||||||
|
**/*.sqlite-shm
|
||||||
|
**/*.sqlite-wal
|
||||||
|
**/*.sqlite-journal
|
||||||
|
data
|
||||||
|
backups
|
||||||
|
client/pnpm-lock.yaml
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# A distinct project name and host port let v1 and v2 run side by side.
|
||||||
|
COMPOSE_PROJECT_NAME=announcements-v2
|
||||||
|
WEB_PORT=5175
|
||||||
|
|
||||||
|
# Docker Compose pins these internal values. They are shown here to document the
|
||||||
|
# standalone API configuration contract; do not uncomment them for normal Compose use.
|
||||||
|
# NODE_ENV=production
|
||||||
|
# HOST=0.0.0.0
|
||||||
|
# PORT=7532
|
||||||
|
# DATABASE_PATH=/data/db/announcements.sqlite
|
||||||
|
# S3_ENDPOINT=http://seaweedfs:8333
|
||||||
|
# S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# Optional local Vite development proxy target (not used by Compose).
|
||||||
|
# VITE_API_PROXY_TARGET=http://127.0.0.1:7532
|
||||||
|
|
||||||
|
# Application configuration exposed by Compose
|
||||||
|
APP_TIMEZONE=America/Chicago
|
||||||
|
SHUTDOWN_TIMEOUT_MS=10000
|
||||||
|
DATABASE_BUSY_TIMEOUT_MS=5000
|
||||||
|
TRUST_PROXY=true
|
||||||
|
|
||||||
|
# Private SeaweedFS S3 configuration
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET=announcements-v2
|
||||||
|
S3_ACCESS_KEY_ID=replace-with-a-random-access-key
|
||||||
|
S3_SECRET_ACCESS_KEY=replace-with-a-long-random-secret
|
||||||
|
S3_CONNECT_TIMEOUT_MS=2000
|
||||||
|
# Maximum socket inactivity during one S3 operation (not a total upload deadline).
|
||||||
|
S3_REQUEST_TIMEOUT_MS=5000
|
||||||
|
S3_MAX_ATTEMPTS=2
|
||||||
|
S3_HEALTH_PROBE_INTERVAL_MS=30000
|
||||||
|
|
||||||
|
# Image upload and decode limits. The default proxy body limit is 512 MiB,
|
||||||
|
# which accommodates the default twenty 25 MiB files plus multipart overhead.
|
||||||
|
UPLOAD_MAX_FILE_BYTES=26214400
|
||||||
|
UPLOAD_MAX_FILES=20
|
||||||
|
IMAGE_MAX_INPUT_PIXELS=40000000
|
||||||
|
# UPLOAD_TEMP_DIRECTORY=/tmp/announcements-uploads
|
||||||
|
|
||||||
|
# Optional integrations. Leave MQTT_URL blank to disable the MQTT integration
|
||||||
|
# and HA_WEBHOOK_URL blank to disable outgoing notifications entirely.
|
||||||
|
MQTT_URL=
|
||||||
|
MQTT_USERNAME=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
# With the default prefix the command topics are announcements/mode,
|
||||||
|
# announcements/next, announcements/previous, announcements/pause,
|
||||||
|
# announcements/resume, announcements/reload, and announcements/static.
|
||||||
|
MQTT_TOPIC_PREFIX=announcements
|
||||||
|
MQTT_RECONNECT_PERIOD_MS=5000
|
||||||
|
MQTT_CONNECT_TIMEOUT_MS=10000
|
||||||
|
|
||||||
|
# Outgoing Home Assistant webhook. The URL is a secret: it is never returned by
|
||||||
|
# any API response or status document.
|
||||||
|
HA_WEBHOOK_URL=
|
||||||
|
HA_WEBHOOK_SECRET=
|
||||||
|
HA_WEBHOOK_TIMEOUT_MS=10000
|
||||||
|
|
||||||
|
# A display is reported offline after this long without a valid heartbeat.
|
||||||
|
# The display client is expected to send one every 10-15 seconds.
|
||||||
|
DISPLAY_OFFLINE_THRESHOLD_MS=45000
|
||||||
+16
-1
@@ -1 +1,16 @@
|
|||||||
node_modules
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Local configuration and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Local build and runtime data
|
||||||
|
dist/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.sqlite-journal
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -0,0 +1,748 @@
|
|||||||
|
# Announcements v2
|
||||||
|
|
||||||
|
Announcements is a small Express, React, Socket.IO, MQTT, SQLite, and
|
||||||
|
S3-compatible signage application. Version 2 is being built in phases and is
|
||||||
|
intended to run beside the existing production version until cutover.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The repository is a pnpm workspace with one lockfile. It requires Node.js
|
||||||
|
22.13 or newer and pnpm 10.29.3.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm check
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the Nginx web service publishes a host port (5175 by default). The API and
|
||||||
|
SeaweedFS S3 endpoint remain private on Compose networks. Replace the example
|
||||||
|
S3 credentials before starting the stack.
|
||||||
|
|
||||||
|
The web service also joins the external `tabernet` network under the alias
|
||||||
|
`announcements-web`, so a shared reverse proxy reaches it as
|
||||||
|
`announcements-web:80`. The API and SeaweedFS deliberately stay off that
|
||||||
|
network and remain private to Announcements. The network must already exist:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker network create tabernet # only if it does not already exist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Library assets
|
||||||
|
|
||||||
|
`POST /api/assets` accepts multipart uploads in the repeated `file` or `files`
|
||||||
|
field. An optional `display_name` (or `displayName`) is accepted for a
|
||||||
|
single-file upload. Supported decoded formats are still JPEG, PNG, and WebP;
|
||||||
|
extensions and multipart content types are not trusted. Animated, multipage,
|
||||||
|
malformed, unsupported, oversized, and excessive-pixel inputs are rejected.
|
||||||
|
|
||||||
|
Default limits are:
|
||||||
|
|
||||||
|
- 25 MiB per file (`UPLOAD_MAX_FILE_BYTES`)
|
||||||
|
- 20 files per request (`UPLOAD_MAX_FILES`)
|
||||||
|
- 40 million decoded pixels per image (`IMAGE_MAX_INPUT_PIXELS`)
|
||||||
|
|
||||||
|
Nginx accepts up to 512 MiB per request, enough for the default aggregate plus
|
||||||
|
multipart overhead. If the backend limits are raised, update
|
||||||
|
`client_max_body_size` in `client/nginx.conf` as well. Proxied API requests use
|
||||||
|
a ten-minute inactivity timeout so a valid large batch can finish processing.
|
||||||
|
|
||||||
|
Original bytes are archived unchanged. Content identity is the SHA-256 of
|
||||||
|
those bytes, so uploading the same content returns the existing asset even
|
||||||
|
when the filename differs. Display-name collisions with different content
|
||||||
|
return `409 ASSET_NAME_CONFLICT`. Multi-file uploads validate every new image
|
||||||
|
locally before persisting any of them. If a later storage operation fails,
|
||||||
|
already completed items remain valid and retrying the request is idempotent.
|
||||||
|
|
||||||
|
Sharp produces two metadata-free, auto-oriented WebP renditions:
|
||||||
|
|
||||||
|
- display: inside-fit at most 1920×1080, quality 82
|
||||||
|
- thumbnail: inside-fit at most 480×270, quality 72
|
||||||
|
|
||||||
|
Neither rendition crops or upscales. Transparency is flattened against black.
|
||||||
|
Presentation effects such as blur, shadows, darkening, and framing are not
|
||||||
|
baked into stored media.
|
||||||
|
|
||||||
|
## Asset and media API
|
||||||
|
|
||||||
|
- `GET /api/assets`
|
||||||
|
- `POST /api/assets`
|
||||||
|
- `GET /api/assets/:assetId`
|
||||||
|
- `PATCH /api/assets/:assetId`
|
||||||
|
- `GET /api/assets/:assetId/references`
|
||||||
|
- `DELETE /api/assets/:assetId`
|
||||||
|
- `GET /api/assets/:assetId/original`
|
||||||
|
- `GET /media/assets/:assetId/display/:sha256.webp`
|
||||||
|
- `GET /media/assets/:assetId/thumbnail/:sha256.webp`
|
||||||
|
|
||||||
|
PATCH accepts only display name and expiration. A null expiration is permanent.
|
||||||
|
Manual deletion refuses playlist, current playback, and static references. It
|
||||||
|
marks an asset as deleting before idempotently removing its three objects and
|
||||||
|
removes SQLite metadata only after storage succeeds. A failed processing row
|
||||||
|
can be inspected, safely deleted, and uploaded again; no automatic retry loop
|
||||||
|
is run.
|
||||||
|
|
||||||
|
Media URLs are derived from SQLite metadata and never accept object keys. The
|
||||||
|
display and thumbnail routes stream private S3 objects through the API with a
|
||||||
|
one-year immutable cache policy. Original downloads use private, no-store
|
||||||
|
caching.
|
||||||
|
|
||||||
|
## Playlists
|
||||||
|
|
||||||
|
Playlist and entry metadata is authoritative in SQLite. The API surface is:
|
||||||
|
|
||||||
|
- `GET /api/playlists`
|
||||||
|
- `POST /api/playlists`
|
||||||
|
- `GET /api/playlists/:playlistId`
|
||||||
|
- `PATCH /api/playlists/:playlistId`
|
||||||
|
- `DELETE /api/playlists/:playlistId`
|
||||||
|
- `POST /api/playlists/:playlistId/entries`
|
||||||
|
- `PATCH /api/playlists/:playlistId/entries/:entryId`
|
||||||
|
- `DELETE /api/playlists/:playlistId/entries/:entryId`
|
||||||
|
- `PUT /api/playlists/:playlistId/entries/order`
|
||||||
|
- `POST /api/playlists/:playlistId/activate`
|
||||||
|
|
||||||
|
Entry positions are zero-based and contiguous. Reordering requires the complete
|
||||||
|
ordered list as `{ "entryIds": [...] }`. An asset can occur only once in a
|
||||||
|
playlist, but the same asset can be used independently by any number of other
|
||||||
|
playlists. Only ready, unexpired assets can be newly added.
|
||||||
|
|
||||||
|
Entry duration overrides may be null (inherit the global setting) or 1,000 to
|
||||||
|
3,600,000 milliseconds. Presentation overrides are nullable; darkening is an
|
||||||
|
inherit/off/on boolean and blur always comes from the global setting. Schedule
|
||||||
|
instants use epoch milliseconds or ISO strings with an explicit `Z` or numeric
|
||||||
|
UTC offset, and intervals are evaluated as `[start, end)`.
|
||||||
|
|
||||||
|
Playlist detail responses include effective duration and presentation,
|
||||||
|
server-evaluated eligibility, immutable display/thumbnail URLs, and the
|
||||||
|
settings revision and evaluation time used. Playlist revisions increment once
|
||||||
|
per semantic content change. Exact no-ops and reads do not increment them.
|
||||||
|
|
||||||
|
Before a playback-relevant playlist, settings, or asset mutation, the playback
|
||||||
|
service catches up using the old metadata. The domain transaction then commits
|
||||||
|
and playback synchronously reconciles the new values at the mutation instant.
|
||||||
|
This prevents a late timer or restart boundary from being rewritten by a later
|
||||||
|
edit. If the post-commit reconciliation unexpectedly fails, the API returns an
|
||||||
|
error even though the preceding domain commit may already be durable; clients
|
||||||
|
must refetch current state before deciding whether to retry.
|
||||||
|
|
||||||
|
Activation is explicit. A request is held in the existing pending-activation
|
||||||
|
state while a playable normal slide is mid-duration; otherwise the target
|
||||||
|
playlist is prepared immediately at its first currently playable entry. The
|
||||||
|
output-off flag remains only a visual gate, and static hold is preserved while
|
||||||
|
the hidden normal playlist changes. The authoritative playback service consumes
|
||||||
|
a pending activation at the current slide boundary and never merges the old and
|
||||||
|
new playlists.
|
||||||
|
|
||||||
|
Socket.IO publishes `playlists:changed` as a refetch invalidation after
|
||||||
|
committed playlist changes and after settings or referenced-asset edits that
|
||||||
|
affect resolved playlist data. Socket.IO is not a mutation API.
|
||||||
|
|
||||||
|
## Authoritative playback
|
||||||
|
|
||||||
|
One serialized service owns persisted global playback, timer reconciliation,
|
||||||
|
and every playback control path. Its REST surface is:
|
||||||
|
|
||||||
|
- `GET /api/playback`
|
||||||
|
- `POST /api/playback/pause`
|
||||||
|
- `POST /api/playback/resume`
|
||||||
|
- `POST /api/playback/next`
|
||||||
|
- `POST /api/playback/previous`
|
||||||
|
- `POST /api/playback/static`
|
||||||
|
- `PUT /api/playback/mode`
|
||||||
|
- `POST /api/playback/reload`
|
||||||
|
|
||||||
|
Visual output precedence is mode off (black), a valid static asset, the current
|
||||||
|
normal entry, then black when no playable target exists. Mode off is only a
|
||||||
|
visual gate: normal playback continues underneath unless it is independently
|
||||||
|
paused or held by static mode. Pause and static both preserve the normal cursor
|
||||||
|
and remaining duration. Resume clears both static and paused state together and
|
||||||
|
continues the normal entry with its preserved remaining time. Next and previous
|
||||||
|
wrap across currently playable entries; they change the held entry without
|
||||||
|
unpausing when paused and are no-ops while static.
|
||||||
|
|
||||||
|
Normal slides use persisted absolute start times and deadlines. Chained
|
||||||
|
timeouts reconcile from the prior authoritative deadline, including late
|
||||||
|
callbacks and restart catch-up, so delays do not accumulate as timing drift.
|
||||||
|
The service also arms exact schedule and asset-expiration boundaries. It skips
|
||||||
|
disabled, out-of-schedule, expired, and non-ready entries, and safely returns to
|
||||||
|
an eligible entry when one becomes available. Pending playlist activation is
|
||||||
|
applied at the current normal slide boundary; an empty target playlist still
|
||||||
|
becomes active with black normal output.
|
||||||
|
|
||||||
|
Socket.IO sends `playback:state` immediately on connection and after meaningful
|
||||||
|
committed playback changes. The authoritative snapshot includes server time and
|
||||||
|
revisions, mode and transport state, active playlist and timeline data, current
|
||||||
|
and static targets, crossfade settings, and a display-cache manifest with
|
||||||
|
effective duration, presentation, scheduling, expiration, and immutable media
|
||||||
|
metadata. Enabled future-scheduled entries remain in the manifest; disabled
|
||||||
|
entries and private S3 details do not. Reads and connections do not increment
|
||||||
|
the playback revision.
|
||||||
|
|
||||||
|
Phase 3B exposes this state and behavior through REST and Socket.IO only. MQTT
|
||||||
|
command wiring and the browser display renderer remain later phases.
|
||||||
|
|
||||||
|
Deleting a library asset that is the current static target is still refused by
|
||||||
|
the Phase 2 deletion model. That refusal is a known interim integration point,
|
||||||
|
not the intended final behavior: deleting the statically held slide is meant to
|
||||||
|
exit static safely, return to normal rotation, and only then delete the asset.
|
||||||
|
Phase 3B does not depend on the refusal, so the change belongs to the later
|
||||||
|
phase that revisits asset lifecycle.
|
||||||
|
|
||||||
|
## MQTT integration
|
||||||
|
|
||||||
|
The API connects to `MQTT_URL` when it is configured and stays disabled when it
|
||||||
|
is not. Commands are dispatched straight into the authoritative playback
|
||||||
|
service, so an MQTT command and its REST equivalent are the same operation with
|
||||||
|
the same semantics; no playback logic is duplicated in the integration.
|
||||||
|
|
||||||
|
With the default `MQTT_TOPIC_PREFIX` of `announcements` the command topics are:
|
||||||
|
|
||||||
|
| Topic | Payload | QoS |
|
||||||
|
| ------------------------- | ---------------------- | --- |
|
||||||
|
| `announcements/mode` | `on` or `off` | 1 |
|
||||||
|
| `announcements/pause` | ignored | 1 |
|
||||||
|
| `announcements/resume` | ignored | 1 |
|
||||||
|
| `announcements/reload` | ignored | 1 |
|
||||||
|
| `announcements/static` | LibraryAsset UUID | 1 |
|
||||||
|
| `announcements/next` | ignored | 0 |
|
||||||
|
| `announcements/previous` | ignored | 0 |
|
||||||
|
|
||||||
|
Mode payloads are trimmed and case-insensitive. `off` is only the visual black
|
||||||
|
gate; it never pauses, restarts, or resets the playlist, cursor, static hold, or
|
||||||
|
timing. Static accepts only a stable LibraryAsset UUID: URLs, filenames, and S3
|
||||||
|
object keys are rejected. The asset must exist, be `ready`, and be unexpired,
|
||||||
|
but it does not need to appear in any playlist. A rejected command is logged as
|
||||||
|
a structured warning, changes nothing, and publishes no state.
|
||||||
|
|
||||||
|
`next` and `previous` stay at QoS 0 on purpose. They are not idempotent and
|
||||||
|
carry no command ID, so an at-least-once redelivery would silently skip a
|
||||||
|
slide. Commands are never retained, and the server never publishes onto a
|
||||||
|
command topic.
|
||||||
|
|
||||||
|
The server publishes retained state on separate topics:
|
||||||
|
|
||||||
|
| Topic | Payload |
|
||||||
|
| --------------------------------- | ---------------------------------- |
|
||||||
|
| `announcements/availability` | `online` / `offline` |
|
||||||
|
| `announcements/mode/state` | `on` / `off` |
|
||||||
|
| `announcements/playback/state` | small versioned JSON snapshot |
|
||||||
|
|
||||||
|
Availability describes this API's MQTT integration, not a display. A registered
|
||||||
|
last will and testament lets the broker publish `offline` when the API
|
||||||
|
disappears without a clean disconnect; a planned shutdown retracts it directly.
|
||||||
|
The compact playback topic carries mode, transport, active playlist, current
|
||||||
|
entry and asset, static asset, and revision. It never carries the Socket.IO
|
||||||
|
manifest or any private storage detail.
|
||||||
|
|
||||||
|
State is published only after a playback mutation has committed, and MQTT
|
||||||
|
publication happens after the SQLite commit. A publish failure degrades the
|
||||||
|
integration and is never allowed to roll playback back. A broker outage leaves
|
||||||
|
the API, HTTP, and playback untouched: the client reconnects on its own, then
|
||||||
|
resubscribes and republishes availability and authoritative state.
|
||||||
|
|
||||||
|
## Home Assistant notifications
|
||||||
|
|
||||||
|
Announcements sends outgoing generic webhook POSTs to `HA_WEBHOOK_URL`. Home
|
||||||
|
Assistant is only the receiver; Announcements never calls Companion directly and
|
||||||
|
needs no Home Assistant authentication beyond the configured webhook. The URL
|
||||||
|
and `HA_WEBHOOK_SECRET` are configuration, never API or status output.
|
||||||
|
|
||||||
|
Notifications are durable and deduplicated through `notification_incidents` and
|
||||||
|
`notification_outbox`. A condition that starts failing opens one incident and
|
||||||
|
queues exactly one failure notification. While it keeps failing only its
|
||||||
|
observation time is updated, so a thirty-minute outage still sends one message.
|
||||||
|
Recovery closes the incident and queues exactly one recovery notification. If
|
||||||
|
the same condition fails again later that is a new episode with its own
|
||||||
|
failure/recovery pair. Open incidents and undelivered notifications survive an
|
||||||
|
API restart.
|
||||||
|
|
||||||
|
Reported conditions are `storage.unavailable` / `storage.recovered`, driven by
|
||||||
|
the Phase 2 storage health transition rather than individual request failures;
|
||||||
|
`display.offline` / `display.recovered` per display client; and
|
||||||
|
`playback.no_playable_slides` / `playback.playable` from committed playback
|
||||||
|
state. A static override does not suppress the playback condition, because
|
||||||
|
holding one asset does not repair an empty playlist. A failed upload opens one
|
||||||
|
`asset.processing_failed` warning per asset; an individual failed upload has no
|
||||||
|
automatic recovery, so the operator resolves it by deleting or re-uploading.
|
||||||
|
|
||||||
|
The payload is versioned:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"notificationId": "stable-delivery-id",
|
||||||
|
"incidentId": "stable-incident-id",
|
||||||
|
"event": "storage.unavailable",
|
||||||
|
"severity": "critical",
|
||||||
|
"state": "failure",
|
||||||
|
"message": "Announcement storage is unavailable.",
|
||||||
|
"timestamp": "2026-08-16T12:00:00.000Z",
|
||||||
|
"context": { "conditionKey": "storage", "episode": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Severities are `info`, `warning`, `critical`, and `recovery`. Home Assistant can
|
||||||
|
treat them all as ordinary notifications; the field exists so later Ekklesia or
|
||||||
|
HA handling can discriminate.
|
||||||
|
|
||||||
|
Delivery is asynchronous and bounded. A 2xx marks the notification delivered.
|
||||||
|
Any other response and any transport failure leaves it pending and retries after
|
||||||
|
30 seconds, 1 minute, 5 minutes, 15 minutes, and 30 minutes, then every 30
|
||||||
|
minutes. The notification ID is stable across retries so a receiver can
|
||||||
|
deduplicate, retries resume after a restart, and a failing webhook never blocks
|
||||||
|
playback, MQTT, storage, or heartbeats. When no webhook URL is configured,
|
||||||
|
incidents are still recorded but no delivery work is queued, and status reports
|
||||||
|
the integration as disabled.
|
||||||
|
|
||||||
|
An example Home Assistant receiver:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
automation:
|
||||||
|
- alias: Announcements Notifications
|
||||||
|
triggers:
|
||||||
|
- trigger: webhook
|
||||||
|
webhook_id: announcements
|
||||||
|
allowed_methods:
|
||||||
|
- POST
|
||||||
|
local_only: true
|
||||||
|
|
||||||
|
actions:
|
||||||
|
- action: notify.mobile_app_YOUR_DEVICE
|
||||||
|
data:
|
||||||
|
title: "Announcements"
|
||||||
|
message: "{{ trigger.json.message }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Substitute any notify target; Companion is not required. Announcements cannot
|
||||||
|
send a webhook while the Announcements API itself is completely down. That is a
|
||||||
|
limitation of self-reporting, not a bug: if server-down alerting is wanted, an
|
||||||
|
external monitor such as Home Assistant should poll `/health/live`.
|
||||||
|
|
||||||
|
## Display telemetry
|
||||||
|
|
||||||
|
The future `/display` client reports read-only telemetry over the Socket.IO
|
||||||
|
event `display:heartbeat`, expected every 10-15 seconds:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"clientId": "persistent-display-id",
|
||||||
|
"outputId": "global",
|
||||||
|
"activePlaylistId": "playlist-id",
|
||||||
|
"playlistRevision": 12,
|
||||||
|
"playbackRevision": 42,
|
||||||
|
"currentEntryId": "entry-id",
|
||||||
|
"currentAssetId": "asset-id",
|
||||||
|
"cachedAssetCount": 10,
|
||||||
|
"playableAssetCount": 10,
|
||||||
|
"mostRecentError": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every field is validated and bounded, `mostRecentError` is truncated, and
|
||||||
|
malformed telemetry is ignored without creating a display. Heartbeats can never
|
||||||
|
mutate playback and never increment a playback or playlist revision. Identity is
|
||||||
|
the persistent `clientId` stored in `display_clients`, never a Socket.IO
|
||||||
|
connection ID, so a browser reconnect remains the same display.
|
||||||
|
|
||||||
|
A display is online while heartbeats are arriving and offline once
|
||||||
|
`DISPLAY_OFFLINE_THRESHOLD_MS` (45 seconds, about three missed heartbeats) has
|
||||||
|
passed since the last one. A closed socket alone never marks a display offline,
|
||||||
|
because reconnects and brief network hiccups are normal. Crossing the threshold
|
||||||
|
opens one `display.offline` incident that does not repeat while the display
|
||||||
|
stays away, and the next valid heartbeat recovers it exactly once.
|
||||||
|
|
||||||
|
Display history is persisted, so a restart does not erase monitoring. The API
|
||||||
|
cannot know what happened to a display while the API itself was down, so it is
|
||||||
|
deliberately conservative: nothing is reported offline until a full threshold has
|
||||||
|
elapsed since startup. A healthy display that reconnects during that window is
|
||||||
|
never reported, a display that is still missing afterwards is reported once, and
|
||||||
|
an incident that was already open before the restart is not duplicated.
|
||||||
|
|
||||||
|
`GET /api/displays` returns each known display with a status derived from
|
||||||
|
heartbeat freshness rather than mere database existence, and exposes no socket
|
||||||
|
internals. `display:status` is emitted only for meaningful changes — first seen,
|
||||||
|
offline, recovered, or a changed error — so steady telemetry creates no realtime
|
||||||
|
churn. `mostRecentError` is telemetry only; arbitrary browser strings never
|
||||||
|
become Home Assistant notifications. Structured display-side errors such as
|
||||||
|
decode failures arrive with the Phase 5 renderer, which will report them through
|
||||||
|
this same field and event.
|
||||||
|
|
||||||
|
## Management dashboard
|
||||||
|
|
||||||
|
`/` is the management dashboard and `/display` is the signage output. Both come
|
||||||
|
from the same single-page build; the dashboard's styling is scoped to its own
|
||||||
|
route and never applies to the kiosk.
|
||||||
|
|
||||||
|
Management is desktop-only in v2. Below 768px the dashboard shows a short
|
||||||
|
splash asking for a larger screen. That restriction never applies to
|
||||||
|
`/display`, which renders signage on any screen including a phone.
|
||||||
|
|
||||||
|
Opening `/` goes straight to the playlist that is actually active — no playlist
|
||||||
|
has to be chosen first. The header carries the playlist switcher, the
|
||||||
|
activation state, the display status, and the upload and library actions.
|
||||||
|
|
||||||
|
### Playlists and activation
|
||||||
|
|
||||||
|
The switcher changes which playlist is being *edited*. Selecting a playlist
|
||||||
|
never activates it: an inactive playlist shows `NOT ACTIVE` with an
|
||||||
|
**Activate Playlist** button, and activation goes through
|
||||||
|
`POST /api/playlists/:playlistId/activate`.
|
||||||
|
|
||||||
|
Activation is the server's decision, not the dashboard's. If playback queues the
|
||||||
|
request to the end of the current slide, the header shows `STARTING SOON` and
|
||||||
|
keeps showing the old playlist as active until authoritative playback promotes
|
||||||
|
it. The dashboard never claims a playlist is active before the server says so.
|
||||||
|
Basic playlist creation is available from the header.
|
||||||
|
|
||||||
|
### Slide tiles
|
||||||
|
|
||||||
|
Entries render as large visual tiles in a responsive grid. Position in the grid
|
||||||
|
is the sequence, so tiles carry no ordinal numbers. Names are shown without file
|
||||||
|
extensions and uppercased visually in Roboto; the stored display name keeps its
|
||||||
|
original case.
|
||||||
|
|
||||||
|
Each tile carries a status ring, always accompanied by the same status in text
|
||||||
|
so nothing depends on colour alone:
|
||||||
|
|
||||||
|
| Ring | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| Green | Enabled and currently playable |
|
||||||
|
| Gray | Disabled |
|
||||||
|
| Amber | Enabled but outside its scheduling window |
|
||||||
|
| Red | The asset is unavailable or expired |
|
||||||
|
|
||||||
|
Clicking a tile toggles that **playlist entry's** enabled state. It never
|
||||||
|
changes the library asset, and the same asset stays independently enabled in
|
||||||
|
other playlists. Tiles are dragged to reorder, and the new order is persisted
|
||||||
|
through the playlist API; if the server rejects it, the authoritative order is
|
||||||
|
restored and a message explains why. React identity is always the stable
|
||||||
|
PlaylistEntry UUID, never an array index or position.
|
||||||
|
|
||||||
|
An empty playlist and a playlist whose slides are all currently unplayable are
|
||||||
|
different situations, and the dashboard says which one it is.
|
||||||
|
|
||||||
|
### Uploads and the library
|
||||||
|
|
||||||
|
**Upload Slides** accepts PNG, JPEG, and WebP through the file picker or by
|
||||||
|
dropping files anywhere on the dashboard. Multiple files upload in the order
|
||||||
|
they were selected and are appended to the end of the playlist. Each file shows
|
||||||
|
its own state — queued, uploading, processing, added, already in the library,
|
||||||
|
already in this playlist, or failed — and the dashboard stays usable throughout.
|
||||||
|
PDFs and other formats are refused before upload.
|
||||||
|
|
||||||
|
Content identity is the SHA-256 of the original bytes, so re-uploading the same
|
||||||
|
image reuses the existing library asset instead of storing another copy. A
|
||||||
|
matching filename with different content is a different asset and is never
|
||||||
|
overwritten. An asset can appear in many playlists but only once per playlist.
|
||||||
|
|
||||||
|
The **Library** panel browses existing assets with a name filter, so images can
|
||||||
|
be reused without re-uploading. Assets already in the current playlist are shown
|
||||||
|
as such and cannot be added twice. An asset with no expiration is permanent and
|
||||||
|
is never presented as stale; one with an expiration shows when it expires.
|
||||||
|
|
||||||
|
### Live preview and status
|
||||||
|
|
||||||
|
The dashboard shows a miniature of the real signage output driven by
|
||||||
|
authoritative `playback:state`. It is not a bare thumbnail: it reuses the same
|
||||||
|
presentation resolver and the same scene stylesheet as `/display`, so 16:9 fill,
|
||||||
|
uncropped containment, blurred, solid, and black backgrounds, darkening,
|
||||||
|
foreground shadow, static targets, and the mode-off black gate all render with
|
||||||
|
identical semantics and cannot drift apart. The preview deliberately does not
|
||||||
|
take on kiosk responsibilities — no Service Worker, no offline rotation, no
|
||||||
|
heartbeat, and no display identity of its own.
|
||||||
|
|
||||||
|
Alongside it, one compact indicator reports whether the kiosk is actually
|
||||||
|
online, with last-seen and cached-slide detail, plus the current transport state
|
||||||
|
(rotating, paused, static, or output off). Transport is read-only here; MQTT and
|
||||||
|
the API remain the control paths.
|
||||||
|
|
||||||
|
## Signage display
|
||||||
|
|
||||||
|
`/display` is the signage output and `/` is reserved for the future management
|
||||||
|
dashboard. Both are served by the same single-page build, so `/display` survives
|
||||||
|
direct navigation and refresh through the existing Nginx SPA fallback. The
|
||||||
|
display stays a Chromium kiosk pointed at a web application: v2 makes that
|
||||||
|
application reliable rather than replacing it with a native client.
|
||||||
|
|
||||||
|
### Deployment topology
|
||||||
|
|
||||||
|
The kiosk runs on a different computer from the Announcements host, so the
|
||||||
|
localhost secure-context exception is not available in production. A remote
|
||||||
|
display **must** use HTTPS:
|
||||||
|
|
||||||
|
```
|
||||||
|
Remote Chromium kiosk
|
||||||
|
|
|
||||||
|
HTTPS
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Announcements web / reverse proxy
|
||||||
|
|
|
||||||
|
+-- React /display
|
||||||
|
+-- /api
|
||||||
|
+-- /socket.io
|
||||||
|
+-- /media
|
||||||
|
```
|
||||||
|
|
||||||
|
The reverse proxy terminates TLS; the API and web containers may keep speaking
|
||||||
|
plain HTTP behind it. The kiosk browser must trust the certificate — a public
|
||||||
|
certificate or an internal CA installed on the kiosk machine both work.
|
||||||
|
|
||||||
|
Secure-context rules decide what the display can do:
|
||||||
|
|
||||||
|
| Origin | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| `https://display.example.org/display` | Production. Full offline capability. |
|
||||||
|
| `http://localhost:5175/display` | Development and automated testing only. Secure context, so offline works. |
|
||||||
|
| `http://10.0.0.5:5175/display` | Degraded, online only. **Not a valid kiosk configuration.** |
|
||||||
|
|
||||||
|
Plain LAN HTTP has no Service Worker, no Cache Storage, and no offline startup.
|
||||||
|
The display still shows slides while the server is reachable, but it cannot
|
||||||
|
survive an outage. It detects this at startup and reports
|
||||||
|
`offline_capability_unavailable` through its heartbeat so the limitation is
|
||||||
|
visible in `/api/displays` and Home Assistant rather than on screen.
|
||||||
|
|
||||||
|
Do not work around this with `--ignore-certificate-errors`,
|
||||||
|
`--disable-web-security`, or an insecure-origin-as-secure flag. They disable
|
||||||
|
the protections the offline design relies on and do not represent a supportable
|
||||||
|
deployment.
|
||||||
|
|
||||||
|
### Kiosk browser requirements
|
||||||
|
|
||||||
|
The kiosk must use a **persistent** Chromium profile. The Service Worker, Cache
|
||||||
|
Storage, and IndexedDB that make offline operation possible all live in that
|
||||||
|
profile, and an incognito window, a guest profile, or a throwaway
|
||||||
|
`--user-data-dir` discards them on every launch.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chromium \
|
||||||
|
--kiosk \
|
||||||
|
--user-data-dir=/var/lib/announcements-kiosk \
|
||||||
|
https://display.example.org/display
|
||||||
|
```
|
||||||
|
|
||||||
|
The old v1 kiosk flags that disabled the browser cache are no longer required
|
||||||
|
and must be removed at cutover. They existed because v1 reused mutable slide
|
||||||
|
URLs; v2 media URLs are content-addressed, so a changed image always arrives
|
||||||
|
under a new URL and stale bytes are impossible. Those flags now actively
|
||||||
|
interfere with the Service Worker and Cache Storage the display depends on.
|
||||||
|
|
||||||
|
Clearing the browser's site data deletes the cached slides, the last-known-good
|
||||||
|
state, and the display's identity. Nothing is lost permanently: on its next
|
||||||
|
connection the display registers as a new client, re-caches the active playlist,
|
||||||
|
and resumes. No routine cache management is expected of an operator.
|
||||||
|
|
||||||
|
### Transition guarantee
|
||||||
|
|
||||||
|
The renderer owns two permanent full-screen scene layers. Each layer is a
|
||||||
|
complete scene — background, optional darkening overlay, and foreground image —
|
||||||
|
and neither layer is ever recreated; only its contents and opacity change.
|
||||||
|
|
||||||
|
A transition prepares the hidden layer completely, decodes the incoming image
|
||||||
|
through `Image.decode()` on the element that will actually be composited, then
|
||||||
|
raises that layer above the current one at opacity 0 and fades it in. The
|
||||||
|
outgoing layer stays fully opaque underneath for the whole transition and is
|
||||||
|
never faded out. This is the specific v1 defect that produced black frames: two
|
||||||
|
simultaneous fades let the black root show through the composite.
|
||||||
|
|
||||||
|
If an incoming asset cannot be fetched or decoded, the last good frame simply
|
||||||
|
stays on screen. The entry is marked locally unavailable, a structured error is
|
||||||
|
recorded for telemetry, and rotation continues past it rather than retrying it
|
||||||
|
in a loop. Every asynchronous load and transition carries a generation token, so
|
||||||
|
work that completes after authoritative state has moved on is discarded instead
|
||||||
|
of overwriting the newer target.
|
||||||
|
|
||||||
|
While connected, the display schedules the expected next transition locally from
|
||||||
|
the authoritative timeline and the cached manifest, so transitions stay smooth
|
||||||
|
between packets. The server always wins any disagreement. When an activation is
|
||||||
|
pending for a playlist the display does not yet hold, local prediction is
|
||||||
|
withheld entirely: holding the current frame a little longer is better than
|
||||||
|
briefly showing the wrong slide.
|
||||||
|
|
||||||
|
### Presentation
|
||||||
|
|
||||||
|
A slide whose real pixel dimensions are 16:9 within a small tolerance fills the
|
||||||
|
frame: no blurred duplicate, no pillarbox, and no foreground shadow. Anything
|
||||||
|
else is shown whole and centred with `object-fit: contain`, never cropped, over
|
||||||
|
its configured background and with a subtle drop shadow.
|
||||||
|
|
||||||
|
Backgrounds follow the server's already-resolved effective values. `blurred`
|
||||||
|
reuses the same slide image, covered and centred, blurred by the global setting
|
||||||
|
(20px by default) and overscanned so the blur cannot reveal an edge. `solid`
|
||||||
|
fills with the configured colour and `black` fills with black. Background
|
||||||
|
darkening is boolean, resolved by the server from the global default and the
|
||||||
|
entry's inherit/off/on override, and applies a fixed overlay of about 0.25 to
|
||||||
|
the background only — never to the readable foreground. Blur is applied at
|
||||||
|
render time and is never baked into a stored derivative.
|
||||||
|
|
||||||
|
Static targets have no playlist entry, so they use the global presentation
|
||||||
|
defaults the snapshot already carries, through the same renderer and the same
|
||||||
|
transition path.
|
||||||
|
|
||||||
|
`mode=off` is a topmost black gate that covers the output immediately. The
|
||||||
|
renderer keeps tracking the timeline, caching, and updating its current target
|
||||||
|
underneath, so switching the mode back on reveals the synchronized scene without
|
||||||
|
restarting the playlist.
|
||||||
|
|
||||||
|
### Explicit asset cache
|
||||||
|
|
||||||
|
Slide bytes are stored explicitly in Cache Storage under
|
||||||
|
`announcements-display-assets-v1`, not left to ordinary HTTP caching. The asset
|
||||||
|
manager checks the cache, fetches only on a miss, validates the response, stores
|
||||||
|
a clone, and returns a blob that is decoded before it is ever presented. Object
|
||||||
|
URLs are reference counted, and bytes are never revoked while a scene layer is
|
||||||
|
still displaying them.
|
||||||
|
|
||||||
|
A new manifest never destroys the working one. Incoming assets are fetched and
|
||||||
|
validated first and the manifest is promoted once it is usable; one unfetchable
|
||||||
|
asset marks only itself unavailable rather than rejecting the whole playlist.
|
||||||
|
After promotion, anything not referenced by the promoted manifest, the current
|
||||||
|
static target, or a live scene layer is pruned. Quota pressure triggers a prune
|
||||||
|
and one retry before a bounded error is reported.
|
||||||
|
|
||||||
|
### Persistence and the app shell
|
||||||
|
|
||||||
|
A small IndexedDB database (`announcements-display`, version 1, one object
|
||||||
|
store, explicit upgrade handling) holds the persistent client ID, the
|
||||||
|
last-known-good manifest with the revisions needed to interpret it, the last
|
||||||
|
authoritative timing anchor, mode and transport state, and the most recent
|
||||||
|
structured error. Malformed or superseded records are discarded on read so the
|
||||||
|
display starts cleanly from the server instead of trusting an unreadable
|
||||||
|
snapshot. The client ID is generated once and reused across refresh, reconnect,
|
||||||
|
and Chromium restart; a socket ID is never used as display identity.
|
||||||
|
|
||||||
|
`display-sw.js` caches only the `/display` application shell. Navigations are
|
||||||
|
network-first so an online kiosk always receives the newest application, with
|
||||||
|
the cached shell as the offline fallback, and content-hashed Vite assets are
|
||||||
|
cache-first. The worker explicitly bypasses `/media`, `/api`, `/socket.io`, and
|
||||||
|
`/health`: slide bytes belong to the asset manager and live server state must
|
||||||
|
never be answered from a cache. It calls `skipWaiting` and `clients.claim`, so a
|
||||||
|
kiosk cannot be trapped on a stale version.
|
||||||
|
|
||||||
|
### Heartbeat and errors
|
||||||
|
|
||||||
|
The display emits `display:heartbeat` about every 12 seconds with its persistent
|
||||||
|
client ID, the active playlist and revisions it is actually rendering, the
|
||||||
|
current entry and asset, its true cached and locally playable asset counts, and
|
||||||
|
its most recent error. Telemetry is read-only and never mutates playback.
|
||||||
|
|
||||||
|
Errors are structured internally as `slide_decode_failed`, `asset_fetch_failed`,
|
||||||
|
`cache_write_failed`, or `manifest_invalid`, each with the relevant asset or
|
||||||
|
entry, a bounded message, and a timestamp. They are sent as bounded JSON, which
|
||||||
|
the server's existing string validation already accepts, so no schema migration
|
||||||
|
was required; `/api/displays` parses them back into an object. An error clears
|
||||||
|
when the failing asset renders successfully or leaves the manifest — never
|
||||||
|
merely because time passed.
|
||||||
|
|
||||||
|
### Offline resilience
|
||||||
|
|
||||||
|
While connected the server is authoritative. While disconnected the display
|
||||||
|
becomes a deterministic local continuation of the last authoritative state it
|
||||||
|
persisted. The moment the server speaks again it is authoritative once more:
|
||||||
|
the local cursor is abandoned, never reconciled and never reported back.
|
||||||
|
Offline playback is resilience, not distributed consensus.
|
||||||
|
|
||||||
|
Local continuation uses the persisted manifest and timing anchor. Elapsed time
|
||||||
|
is derived from the anchor — the authoritative `serverTime` and the local clock
|
||||||
|
reading when it was accepted — so a restart estimates where playback should be
|
||||||
|
rather than restarting a slide. Catch-up is arithmetic: whole rotations are
|
||||||
|
skipped in one step between eligibility boundaries, so a display offline for a
|
||||||
|
month resolves its position in milliseconds instead of stepping through every
|
||||||
|
missed slide. Mixed durations, wraparound, `[start, end)` schedule boundaries,
|
||||||
|
and asset expirations are all evaluated locally from the cached manifest. A null
|
||||||
|
expiration stays permanent no matter how long the display has been away.
|
||||||
|
|
||||||
|
Transport state is preserved exactly. Mode off stays black while the hidden
|
||||||
|
cursor keeps advancing; a paused display holds its slide and remainder and never
|
||||||
|
advances merely because time passed or the browser restarted; static holds
|
||||||
|
indefinitely and releases only when the cached metadata says the asset genuinely
|
||||||
|
expired. If the server had queued an activation for a playlist the display does
|
||||||
|
not hold, the display holds its last good frame at that boundary rather than
|
||||||
|
continuing deeper into a playlist that is being replaced.
|
||||||
|
|
||||||
|
The distinction between "the playlist is genuinely empty" and "the playlist
|
||||||
|
should play but the local bytes are unusable" is preserved: the first is
|
||||||
|
correctly black, the second keeps the last good frame and reports an error.
|
||||||
|
|
||||||
|
This evaluator is a small client-side implementation, deliberately separate from
|
||||||
|
the verified Phase 3B engine. Contract tests run both over identical scenarios —
|
||||||
|
mixed durations, wraparound, schedule starts and ends, expirations, long
|
||||||
|
outages, and empty playlists — and assert they resolve the same slide, start,
|
||||||
|
and deadline.
|
||||||
|
|
||||||
|
### Cold start
|
||||||
|
|
||||||
|
After one successful online visit the display can start with the entire stack
|
||||||
|
unavailable. The Service Worker serves the application shell, IndexedDB
|
||||||
|
provides the client ID and last-known-good state, Cache Storage provides the
|
||||||
|
slide bytes, and local playback resumes from the persisted anchor. A reconnect
|
||||||
|
attempt continues in the background.
|
||||||
|
|
||||||
|
A display that has never successfully cached anything has nothing trustworthy
|
||||||
|
to show, so it stays black. It never invents placeholder content.
|
||||||
|
|
||||||
|
Because a remote kiosk reaches the origin through a reverse proxy, an outage
|
||||||
|
behind that proxy arrives as a 502 rather than a refused connection. The worker
|
||||||
|
treats a gateway error as an outage, and bounds how long it waits for the
|
||||||
|
network when a usable shell is already cached, so a kiosk restarting during an
|
||||||
|
outage reaches its slides quickly instead of waiting out a proxy timeout.
|
||||||
|
|
||||||
|
### Reconnect
|
||||||
|
|
||||||
|
On reconnect the authoritative snapshot wins. Required assets are staged and
|
||||||
|
decoded first and the current frame is kept until the new one is ready, so
|
||||||
|
resynchronization never exposes black and never replays the slides that were
|
||||||
|
missed. If the local calculation already agrees with the server, the timing
|
||||||
|
anchor is simply re-anchored with no visual churn. A playlist changed during the
|
||||||
|
outage is staged and adopted without mixing old and new entries, and the
|
||||||
|
superseded playlist's bytes are pruned once the new manifest is promoted.
|
||||||
|
|
||||||
|
The server's own 45-second heartbeat threshold is unchanged. A display can be
|
||||||
|
showing announcements perfectly while disconnected and still be reported as
|
||||||
|
`display.offline`, which is the useful distinction: the audience sees slides,
|
||||||
|
and the operator still learns that the display lost contact.
|
||||||
|
|
||||||
|
### Display errors as incidents
|
||||||
|
|
||||||
|
Validated display error codes become Home Assistant incidents through the
|
||||||
|
existing Phase 4 machinery, keyed per display and condition so a persisting
|
||||||
|
error is reported once and recovers once:
|
||||||
|
|
||||||
|
| Code | Severity |
|
||||||
|
| --- | --- |
|
||||||
|
| `slide_decode_failed` | warning |
|
||||||
|
| `asset_fetch_failed` | warning |
|
||||||
|
| `cache_write_failed` | warning |
|
||||||
|
| `manifest_invalid` | critical |
|
||||||
|
| `offline_capability_unavailable` | warning |
|
||||||
|
|
||||||
|
Arbitrary browser text remains telemetry and never becomes a notification. If
|
||||||
|
an error occurs while the display is completely disconnected the server cannot
|
||||||
|
learn of it at that instant; the display persists its current error and reports
|
||||||
|
it on reconnect. A transient offline error that fully resolves before
|
||||||
|
reconnection therefore produces no historical notification. This is a documented
|
||||||
|
limitation rather than a browser-side durable event queue.
|
||||||
|
|
||||||
|
## Storage and health
|
||||||
|
|
||||||
|
The API uses a small path-style S3 adapter configured by `S3_ENDPOINT`,
|
||||||
|
`S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, and `S3_SECRET_ACCESS_KEY`.
|
||||||
|
Connection timeout, socket-inactivity timeout (`S3_REQUEST_TIMEOUT_MS`),
|
||||||
|
attempts, and background probe interval are configurable in `.env.example`.
|
||||||
|
Startup verifies or safely creates only the configured bucket.
|
||||||
|
|
||||||
|
Storage outages do not stop the API or fail process readiness. `/health/ready`
|
||||||
|
reports storage as degraded while remaining HTTP 200 when SQLite and the API
|
||||||
|
runtime are healthy. `/api/health` and `/api/status` expose cached, redacted
|
||||||
|
storage state and recover after a successful probe or storage operation.
|
||||||
|
|
||||||
|
`/health/live` reports only that the process is running. Readiness stays limited
|
||||||
|
to SQLite, migrations, and playback initialization. Every external integration
|
||||||
|
degrades the reported status instead of failing readiness, so a broker, webhook
|
||||||
|
receiver, or display problem can never put the API into a restart loop.
|
||||||
|
`/api/health` and `/api/status` report redacted storage, MQTT, notification,
|
||||||
|
playback, and display state. No MQTT password, S3 credential, webhook URL, or
|
||||||
|
webhook secret appears in any response.
|
||||||
@@ -22,3 +22,10 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Playwright browser test artifacts
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
.playwright/
|
||||||
|
debug.mjs
|
||||||
|
|||||||
+18
-8
@@ -1,14 +1,24 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22.23.2-bookworm-slim AS build
|
||||||
|
|
||||||
|
ARG PNPM_VERSION=10.29.3
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
COPY server/package.json ./server/package.json
|
||||||
|
COPY client/package.json ./client/package.json
|
||||||
|
|
||||||
COPY . .
|
RUN pnpm install --frozen-lockfile --filter @announcements/client...
|
||||||
RUN pnpm build
|
|
||||||
|
COPY client ./client
|
||||||
|
|
||||||
|
RUN pnpm --filter @announcements/client build
|
||||||
|
|
||||||
|
FROM nginx:1.30.4-alpine
|
||||||
|
|
||||||
|
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/client/dist /usr/share/nginx/html
|
||||||
|
|
||||||
FROM nginx:alpine
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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]));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist', 'test-results', 'playwright-report']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['**/*.{js,jsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
@@ -18,4 +18,14 @@ export default defineConfig([
|
|||||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
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 } },
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
# Matches the default API allowance of twenty 25 MiB files plus multipart
|
||||||
|
# overhead. Keep this in sync if the backend upload limits are raised.
|
||||||
|
client_max_body_size 512m;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
set $api_origin http://api:7532;
|
||||||
|
|
||||||
|
location ~ ^/(api|health|media)(/|$) {
|
||||||
|
proxy_pass $api_origin;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
# Multipart requests return only after image processing and private-S3
|
||||||
|
# writes complete. This is an inactivity timeout, not a size limit.
|
||||||
|
proxy_read_timeout 10m;
|
||||||
|
proxy_send_timeout 10m;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass $api_origin;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 75s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -1,21 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "@announcements/client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:unit": "node --test \"src/display/__tests__/*.test.js\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "1.62.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|||||||
@@ -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"] } }],
|
||||||
|
});
|
||||||
Generated
-1572
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { io } from "socket.io-client";
|
|
||||||
import "./App.css";
|
|
||||||
|
|
||||||
const socket = io("http://10.40.0.2:7532");
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const [slides, setSlides] = useState([]);
|
|
||||||
const [currentSlide, setCurrentSlide] = useState(0);
|
|
||||||
const [previousSlide, setPreviousSlide] = useState(null);
|
|
||||||
const [mode, setMode] = useState("on");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
socket.on("slideshow:state", (state) => {
|
|
||||||
setSlides(state.slides || []);
|
|
||||||
setMode(state.mode ?? "on");
|
|
||||||
|
|
||||||
setCurrentSlide((oldSlide) => {
|
|
||||||
if (oldSlide !== state.currentSlide) {
|
|
||||||
setPreviousSlide(oldSlide);
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.currentSlide;
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setPreviousSlide(null);
|
|
||||||
}, 1100);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off("slideshow:state");
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (mode === "off") {
|
|
||||||
return <div className="blank-screen" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentImage = slides[currentSlide];
|
|
||||||
const previousImage =
|
|
||||||
previousSlide !== null ? slides[previousSlide] : null;
|
|
||||||
|
|
||||||
if (!currentImage) {
|
|
||||||
return <div className="empty">No slides loaded</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="slideshow">
|
|
||||||
{previousImage && (
|
|
||||||
<img
|
|
||||||
key={`previous-${previousImage}`}
|
|
||||||
className="slide previous"
|
|
||||||
src={previousImage}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<img
|
|
||||||
key={`current-${currentImage}`}
|
|
||||||
className="slide current"
|
|
||||||
src={currentImage}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +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 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, "");
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -7,12 +9,8 @@ body,
|
|||||||
#root {
|
#root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: black;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: block;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
+21
-9
@@ -1,10 +1,22 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import App from './App.jsx'
|
import DashboardApp from "./dashboard/DashboardApp.jsx";
|
||||||
|
import DisplayApp from "./display/DisplayApp.jsx";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
const isDisplayRoute = window.location.pathname.replace(/\/+$/, "") === "/display";
|
||||||
<StrictMode>
|
|
||||||
<App />
|
// The service worker only backs the signage app shell. Slide bytes are owned by
|
||||||
</StrictMode>,
|
// the explicit Cache Storage asset manager, never by the worker.
|
||||||
)
|
if (isDisplayRoute && "serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("/display-sw.js", { scope: "/" }).catch(() => {
|
||||||
|
// A kiosk without a worker still runs; it just loses offline app-shell
|
||||||
|
// startup until the next successful registration.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")).render(
|
||||||
|
<StrictMode>{isDisplayRoute ? <DisplayApp /> : <DashboardApp />}</StrictMode>,
|
||||||
|
);
|
||||||
|
|||||||
+18
-5
@@ -1,7 +1,20 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
export default defineConfig(({ mode }) => {
|
||||||
export default defineConfig({
|
const environment = loadEnv(mode, ".", "VITE_");
|
||||||
|
const target = environment.VITE_API_PROXY_TARGET || "http://127.0.0.1:7532";
|
||||||
|
const proxy = { target, changeOrigin: true };
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
})
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": proxy,
|
||||||
|
"/health": proxy,
|
||||||
|
"/media": proxy,
|
||||||
|
"/socket.io": { ...proxy, ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
+118
-14
@@ -1,20 +1,124 @@
|
|||||||
|
name: "${COMPOSE_PROJECT_NAME:-announcements-v2}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
announcements-server:
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: client/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-5175}:80"
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
app:
|
||||||
|
# Only the web service is reachable from the shared reverse-proxy
|
||||||
|
# network. Caddy resolves it as announcements-web:80; the API and object
|
||||||
|
# storage stay private on the Announcements networks.
|
||||||
|
tabernet:
|
||||||
|
aliases:
|
||||||
|
- announcements-web
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
container_name: announcements-server
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
init: true
|
||||||
- "7532:7532"
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
announcements-client:
|
HOST: 0.0.0.0
|
||||||
build:
|
PORT: "7532"
|
||||||
context: ./client
|
DATABASE_PATH: /data/db/announcements.sqlite
|
||||||
dockerfile: Dockerfile
|
APP_TIMEZONE: "${APP_TIMEZONE:-America/Chicago}"
|
||||||
container_name: announcements-client
|
SHUTDOWN_TIMEOUT_MS: "${SHUTDOWN_TIMEOUT_MS:-10000}"
|
||||||
restart: unless-stopped
|
DATABASE_BUSY_TIMEOUT_MS: "${DATABASE_BUSY_TIMEOUT_MS:-5000}"
|
||||||
ports:
|
TRUST_PROXY: "${TRUST_PROXY:-true}"
|
||||||
- "5174:80"
|
S3_ENDPOINT: http://seaweedfs:8333
|
||||||
|
S3_REGION: "${S3_REGION:-us-east-1}"
|
||||||
|
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
|
||||||
|
S3_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
|
||||||
|
S3_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
|
||||||
|
S3_FORCE_PATH_STYLE: "true"
|
||||||
|
S3_CONNECT_TIMEOUT_MS: "${S3_CONNECT_TIMEOUT_MS:-2000}"
|
||||||
|
S3_REQUEST_TIMEOUT_MS: "${S3_REQUEST_TIMEOUT_MS:-5000}"
|
||||||
|
S3_MAX_ATTEMPTS: "${S3_MAX_ATTEMPTS:-2}"
|
||||||
|
S3_HEALTH_PROBE_INTERVAL_MS: "${S3_HEALTH_PROBE_INTERVAL_MS:-30000}"
|
||||||
|
UPLOAD_TEMP_DIRECTORY: /tmp/announcements-uploads
|
||||||
|
UPLOAD_MAX_FILE_BYTES: "${UPLOAD_MAX_FILE_BYTES:-26214400}"
|
||||||
|
UPLOAD_MAX_FILES: "${UPLOAD_MAX_FILES:-20}"
|
||||||
|
IMAGE_MAX_INPUT_PIXELS: "${IMAGE_MAX_INPUT_PIXELS:-40000000}"
|
||||||
|
MQTT_URL: "${MQTT_URL:-}"
|
||||||
|
MQTT_USERNAME: "${MQTT_USERNAME:-}"
|
||||||
|
MQTT_PASSWORD: "${MQTT_PASSWORD:-}"
|
||||||
|
MQTT_TOPIC_PREFIX: "${MQTT_TOPIC_PREFIX:-announcements}"
|
||||||
|
MQTT_RECONNECT_PERIOD_MS: "${MQTT_RECONNECT_PERIOD_MS:-5000}"
|
||||||
|
MQTT_CONNECT_TIMEOUT_MS: "${MQTT_CONNECT_TIMEOUT_MS:-10000}"
|
||||||
|
HA_WEBHOOK_URL: "${HA_WEBHOOK_URL:-}"
|
||||||
|
HA_WEBHOOK_SECRET: "${HA_WEBHOOK_SECRET:-}"
|
||||||
|
HA_WEBHOOK_TIMEOUT_MS: "${HA_WEBHOOK_TIMEOUT_MS:-10000}"
|
||||||
|
DISPLAY_OFFLINE_THRESHOLD_MS: "${DISPLAY_OFFLINE_THRESHOLD_MS:-45000}"
|
||||||
|
volumes:
|
||||||
|
- app-data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- announcements-server
|
seaweedfs:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- app
|
||||||
|
- storage
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- "fetch('http://127.0.0.1:7532/health/ready').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
|
stop_grace_period: 15s
|
||||||
|
|
||||||
|
seaweedfs:
|
||||||
|
image: chrislusf/seaweedfs:4.39
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["mini", "-dir=/data", "-ip=seaweedfs"]
|
||||||
|
environment:
|
||||||
|
AWS_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
|
||||||
|
AWS_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
|
||||||
|
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
|
||||||
|
volumes:
|
||||||
|
- seaweedfs-data:/data
|
||||||
|
expose:
|
||||||
|
- "8333"
|
||||||
|
networks:
|
||||||
|
- storage
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- >-
|
||||||
|
wget -q -O /dev/null http://127.0.0.1:9333/readyz &&
|
||||||
|
wget -q -O /dev/null http://127.0.0.1:8333/readyz
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app:
|
||||||
|
storage:
|
||||||
|
internal: true
|
||||||
|
tabernet:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-data:
|
||||||
|
seaweedfs-data:
|
||||||
|
|||||||
+14
-19
@@ -1,24 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "Annoucements",
|
"name": "announcements",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "",
|
"private": true,
|
||||||
"main": "index.js",
|
"description": "Church announcement management and signage",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon server/index.js",
|
"dev:server": "pnpm --filter @announcements/server dev",
|
||||||
"start": "node server/index.js"
|
"dev:client": "pnpm --filter @announcements/client dev",
|
||||||
|
"start": "pnpm --filter @announcements/server start",
|
||||||
|
"build": "pnpm --filter @announcements/client build",
|
||||||
|
"lint": "pnpm --filter @announcements/client lint",
|
||||||
|
"test": "pnpm --filter @announcements/server test",
|
||||||
|
"check": "pnpm lint && pnpm test && pnpm build"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"engines": {
|
||||||
"author": "",
|
"node": ">=22.13.0"
|
||||||
"license": "ISC",
|
|
||||||
"packageManager": "[email protected]",
|
|
||||||
"dependencies": {
|
|
||||||
"cors": "^2.8.6",
|
|
||||||
"express": "^5.2.1",
|
|
||||||
"fast-xml-parser": "^5.8.0",
|
|
||||||
"mqtt": "^5.15.1",
|
|
||||||
"socket.io": "^4.8.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"packageManager": "[email protected]"
|
||||||
"nodemon": "^3.1.14"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2262
-243
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
packages:
|
||||||
|
- client
|
||||||
|
- server
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- better-sqlite3
|
||||||
|
- sharp
|
||||||
+24
-5
@@ -1,13 +1,32 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22.23.2-bookworm-slim AS dependencies
|
||||||
|
|
||||||
|
ARG PNPM_VERSION=10.29.3
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN corepack enable
|
RUN apt-get update \
|
||||||
|
&& apt-get install --yes --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
|
COPY server/package.json ./server/package.json
|
||||||
|
COPY client/package.json ./client/package.json
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile --prod --filter @announcements/server...
|
||||||
|
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
|
|
||||||
|
FROM node:22.23.2-bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY --from=dependencies /app/server ./server
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
EXPOSE 7532
|
EXPOSE 7532
|
||||||
|
|
||||||
CMD ["node", "server/index.js"]
|
CMD ["node", "server/src/index.js"]
|
||||||
|
|||||||
-131
@@ -1,131 +0,0 @@
|
|||||||
const express = require("express");
|
|
||||||
const http = require("http");
|
|
||||||
const { Server } = require("socket.io");
|
|
||||||
const cors = require("cors");
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(cors());
|
|
||||||
|
|
||||||
const server = http.createServer(app);
|
|
||||||
|
|
||||||
const io = new Server(server, {
|
|
||||||
cors: {
|
|
||||||
origin: "*",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const PORT = 7532;
|
|
||||||
|
|
||||||
let currentSlide = 0;
|
|
||||||
const slideSeconds = 7;
|
|
||||||
|
|
||||||
const { XMLParser } = require("fast-xml-parser");
|
|
||||||
|
|
||||||
let slides = [];
|
|
||||||
|
|
||||||
async function loadSlides() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
"http://10.40.0.2:9000/announcements?list-type=2"
|
|
||||||
);
|
|
||||||
|
|
||||||
const xml = await response.text();
|
|
||||||
|
|
||||||
const parser = new XMLParser();
|
|
||||||
const data = parser.parse(xml);
|
|
||||||
|
|
||||||
const contents = data.ListBucketResult?.Contents || [];
|
|
||||||
|
|
||||||
const files = Array.isArray(contents)
|
|
||||||
? contents
|
|
||||||
: [contents];
|
|
||||||
|
|
||||||
slides = files
|
|
||||||
.map((file) => file.Key)
|
|
||||||
.filter((name) =>
|
|
||||||
/\.(png|jpg|jpeg|webp)$/i.test(name)
|
|
||||||
)
|
|
||||||
.map((name) => {
|
|
||||||
const encodedName = name
|
|
||||||
.split("/")
|
|
||||||
.map((part) => encodeURIComponent(part))
|
|
||||||
.join("/");
|
|
||||||
|
|
||||||
return `http://10.40.0.2:9000/announcements/${encodedName}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Slides loaded:", slides.length);
|
|
||||||
|
|
||||||
if (currentSlide >= slides.length) {
|
|
||||||
currentSlide = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcastState();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load slides:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mqtt = require("mqtt");
|
|
||||||
|
|
||||||
let mode = "on";
|
|
||||||
|
|
||||||
const mqttClient = mqtt.connect("mqtt://10.40.0.2");
|
|
||||||
|
|
||||||
mqttClient.on("connect", () => {
|
|
||||||
console.log("MQTT connected");
|
|
||||||
mqttClient.subscribe("announcements/mode");
|
|
||||||
});
|
|
||||||
|
|
||||||
mqttClient.on("message", (topic, message) => {
|
|
||||||
if (topic === "announcements/mode") {
|
|
||||||
const value = message.toString().trim().toLowerCase();
|
|
||||||
|
|
||||||
if (value === "on" || value === "off") {
|
|
||||||
mode = value;
|
|
||||||
broadcastState();
|
|
||||||
console.log("Announcement mode:", mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function broadcastState() {
|
|
||||||
io.emit("slideshow:state", {
|
|
||||||
currentSlide,
|
|
||||||
slides,
|
|
||||||
slideSeconds,
|
|
||||||
mode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
io.on("connection", (socket) => {
|
|
||||||
console.log("Client connected:", socket.id);
|
|
||||||
|
|
||||||
socket.emit("slideshow:state", {
|
|
||||||
currentSlide,
|
|
||||||
slides,
|
|
||||||
slideSeconds,
|
|
||||||
mode,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
if (slides.length === 0) return;
|
|
||||||
|
|
||||||
currentSlide = (currentSlide + 1) % slides.length;
|
|
||||||
broadcastState();
|
|
||||||
}, slideSeconds * 1000);
|
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
|
||||||
res.send("Announcement server is running");
|
|
||||||
});
|
|
||||||
|
|
||||||
loadSlides();
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
loadSlides();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`Announcement server running on http://10.40.0.2:${PORT}`);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@announcements/server",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Announcements v2 API and realtime server",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "3.1101.0",
|
||||||
|
"better-sqlite3": "13.0.2",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"mqtt": "5.15.2",
|
||||||
|
"multer": "2.0.2",
|
||||||
|
"sharp": "0.35.3",
|
||||||
|
"socket.io": "^4.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const {
|
||||||
|
createDetailedHealthRouter,
|
||||||
|
createHealthRouter,
|
||||||
|
} = require("./health/routes");
|
||||||
|
const { createAssetRouter } = require("./assets/routes");
|
||||||
|
const { createMediaRouter } = require("./assets/media-routes");
|
||||||
|
const { createAssetService } = require("./assets/service");
|
||||||
|
const { createDisplayRouter } = require("./displays/routes");
|
||||||
|
const { createPlaybackService } = require("./playback");
|
||||||
|
const { createPlaybackRouter } = require("./playback/routes");
|
||||||
|
const {
|
||||||
|
createPlaylistRouter,
|
||||||
|
createPlaylistService,
|
||||||
|
} = require("./playlists");
|
||||||
|
const { createSettingsRouter } = require("./settings/routes");
|
||||||
|
const { createSettingsService } = require("./settings/service");
|
||||||
|
|
||||||
|
function safeNotifier(notifier, logger, failureMessage) {
|
||||||
|
const warn = (error) => {
|
||||||
|
try {
|
||||||
|
logger?.warn?.(failureMessage, error);
|
||||||
|
} catch {
|
||||||
|
// A best-effort notification or warning must not undo a database commit.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (event) => {
|
||||||
|
try {
|
||||||
|
const result = notifier(event);
|
||||||
|
result?.catch?.(warn);
|
||||||
|
} catch (error) {
|
||||||
|
warn(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApp({
|
||||||
|
config,
|
||||||
|
database,
|
||||||
|
displayService,
|
||||||
|
imageProcessor,
|
||||||
|
integrations = {},
|
||||||
|
lifecycle,
|
||||||
|
logger = console,
|
||||||
|
migrations,
|
||||||
|
now = Date.now,
|
||||||
|
onAssetProcessingFailed = () => {},
|
||||||
|
onPlaylistsChanged = () => {},
|
||||||
|
playbackService: suppliedPlaybackService,
|
||||||
|
storage,
|
||||||
|
}) {
|
||||||
|
const app = express();
|
||||||
|
const notifyPlaylistsChanged = safeNotifier(
|
||||||
|
onPlaylistsChanged,
|
||||||
|
logger,
|
||||||
|
"Playlist invalidation notification failed",
|
||||||
|
);
|
||||||
|
const playbackService =
|
||||||
|
suppliedPlaybackService ||
|
||||||
|
createPlaybackService({
|
||||||
|
database,
|
||||||
|
now,
|
||||||
|
onActivationChanged: notifyPlaylistsChanged,
|
||||||
|
});
|
||||||
|
if (!suppliedPlaybackService) {
|
||||||
|
playbackService.initialize();
|
||||||
|
}
|
||||||
|
const reconcilePlaybackDomain = (change) =>
|
||||||
|
playbackService.domainChanged(change);
|
||||||
|
const reconcileBeforeDomainChange = () =>
|
||||||
|
playbackService.reconcile("before_domain_change");
|
||||||
|
const notifyPlaylistMutation = (change) => {
|
||||||
|
reconcilePlaybackDomain(change);
|
||||||
|
const publicChange = { ...change };
|
||||||
|
delete publicChange.invalidBoundaryChanged;
|
||||||
|
notifyPlaylistsChanged(publicChange);
|
||||||
|
};
|
||||||
|
const settingsService = createSettingsService(database);
|
||||||
|
const notifyAssetProcessingFailed = safeNotifier(
|
||||||
|
onAssetProcessingFailed,
|
||||||
|
logger,
|
||||||
|
"Asset processing failure notification failed",
|
||||||
|
);
|
||||||
|
const assetService = createAssetService({
|
||||||
|
database,
|
||||||
|
imageProcessor,
|
||||||
|
onProcessingFailed: notifyAssetProcessingFailed,
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
const playlistService = createPlaylistService({
|
||||||
|
database,
|
||||||
|
now,
|
||||||
|
onBeforeChange: reconcileBeforeDomainChange,
|
||||||
|
onChanged: notifyPlaylistMutation,
|
||||||
|
});
|
||||||
|
app.locals.playbackService = playbackService;
|
||||||
|
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
app.set("trust proxy", config.trustProxy);
|
||||||
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
|
app.get("/api", (request, response) => {
|
||||||
|
response.json({ name: "Announcements API", version: 2 });
|
||||||
|
});
|
||||||
|
app.use(
|
||||||
|
"/health",
|
||||||
|
createHealthRouter({ database, lifecycle, migrations, storage }),
|
||||||
|
);
|
||||||
|
const detailedHealthRouterOptions = {
|
||||||
|
config,
|
||||||
|
database,
|
||||||
|
displayService,
|
||||||
|
integrations,
|
||||||
|
lifecycle,
|
||||||
|
migrations,
|
||||||
|
playbackService,
|
||||||
|
storage,
|
||||||
|
};
|
||||||
|
app.use(
|
||||||
|
"/api/health",
|
||||||
|
createDetailedHealthRouter(detailedHealthRouterOptions),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
"/api/status",
|
||||||
|
createDetailedHealthRouter(detailedHealthRouterOptions),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
"/api/settings",
|
||||||
|
createSettingsRouter(settingsService, {
|
||||||
|
onBeforePlaybackDomainChange: reconcileBeforeDomainChange,
|
||||||
|
onPlaylistsChanged: notifyPlaylistsChanged,
|
||||||
|
onPlaybackDomainChanged: reconcilePlaybackDomain,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
"/api/assets",
|
||||||
|
createAssetRouter({
|
||||||
|
assetService,
|
||||||
|
onBeforePlaybackDomainChange: reconcileBeforeDomainChange,
|
||||||
|
onPlaylistsChanged: notifyPlaylistsChanged,
|
||||||
|
onPlaybackDomainChanged: reconcilePlaybackDomain,
|
||||||
|
processingConfig: config.processing,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
"/api/playlists",
|
||||||
|
createPlaylistRouter(playlistService, {
|
||||||
|
playbackService,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use("/api/playback", createPlaybackRouter(playbackService));
|
||||||
|
if (displayService) {
|
||||||
|
app.use("/api/displays", createDisplayRouter(displayService));
|
||||||
|
}
|
||||||
|
app.use("/media", createMediaRouter(assetService));
|
||||||
|
|
||||||
|
app.use((request, response) => {
|
||||||
|
response.status(404).json({
|
||||||
|
error: {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Route not found",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((error, request, response, next) => {
|
||||||
|
if (response.headersSent) {
|
||||||
|
next(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof SyntaxError && error.status === 400 && "body" in error) {
|
||||||
|
response.status(400).json({
|
||||||
|
error: {
|
||||||
|
code: "INVALID_JSON",
|
||||||
|
message: "Request body must contain valid JSON",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = error.statusCode || 500;
|
||||||
|
response.status(statusCode).json({
|
||||||
|
error: {
|
||||||
|
code: error.code || "INTERNAL_ERROR",
|
||||||
|
message:
|
||||||
|
statusCode >= 500 ? "An internal server error occurred" : error.message,
|
||||||
|
...(error.details ? { details: error.details } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createApp,
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
class AssetError extends Error {
|
||||||
|
constructor(code, message, { statusCode = 400, details, cause } = {}) {
|
||||||
|
super(message, cause ? { cause } : undefined);
|
||||||
|
this.name = "AssetError";
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
if (details !== undefined) {
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetNotFound(assetId) {
|
||||||
|
return new AssetError("ASSET_NOT_FOUND", "Library asset not found", {
|
||||||
|
statusCode: 404,
|
||||||
|
details: { assetId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AssetError,
|
||||||
|
assetNotFound,
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { sendImmutableMedia } = require("./streaming");
|
||||||
|
|
||||||
|
function createMediaRouter(assetService) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
async function sendMedia(request, response, next, kind) {
|
||||||
|
const version = request.params.version;
|
||||||
|
if (!version.endsWith(".webp")) {
|
||||||
|
response.status(404).json({
|
||||||
|
error: {
|
||||||
|
code: "MEDIA_NOT_FOUND",
|
||||||
|
message: "Asset media version not found",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const descriptor = assetService.mediaDescriptor(
|
||||||
|
request.params.assetId,
|
||||||
|
kind,
|
||||||
|
version.slice(0, -".webp".length),
|
||||||
|
);
|
||||||
|
await sendImmutableMedia(assetService, response, descriptor);
|
||||||
|
} catch (error) {
|
||||||
|
if (response.headersSent) {
|
||||||
|
response.destroy(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/assets/:assetId/display/:version", (request, response, next) =>
|
||||||
|
sendMedia(request, response, next, "display"),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
"/assets/:assetId/thumbnail/:version",
|
||||||
|
(request, response, next) =>
|
||||||
|
sendMedia(request, response, next, "thumbnail"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createMediaRouter,
|
||||||
|
};
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
const crypto = require("node:crypto");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const multer = require("multer");
|
||||||
|
const { AssetError } = require("./errors");
|
||||||
|
|
||||||
|
const ALLOWED_FILE_FIELDS = new Set(["file", "files"]);
|
||||||
|
const ALLOWED_TEXT_FIELDS = new Set(["display_name", "displayName"]);
|
||||||
|
const MAX_FIELD_BYTES = 4 * 1024;
|
||||||
|
const MAX_ORIGINAL_FILENAME_LENGTH = 255;
|
||||||
|
const TEMPORARY_UPLOAD_PATHS = Symbol("temporaryUploadPaths");
|
||||||
|
const TEMP_STORAGE_ERROR_CODES = new Set([
|
||||||
|
"EACCES",
|
||||||
|
"EEXIST",
|
||||||
|
"EIO",
|
||||||
|
"EMFILE",
|
||||||
|
"ENFILE",
|
||||||
|
"ENOSPC",
|
||||||
|
"ENOTDIR",
|
||||||
|
"EPERM",
|
||||||
|
"EROFS",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function safeOriginalFilename(value) {
|
||||||
|
const normalized = String(value || "")
|
||||||
|
.replaceAll("\\", "/")
|
||||||
|
.replace(/[\u0000-\u001f\u007f]/g, "");
|
||||||
|
const leafName = path.posix.basename(normalized);
|
||||||
|
const bounded = [...leafName].slice(0, MAX_ORIGINAL_FILENAME_LENGTH).join("");
|
||||||
|
|
||||||
|
if (!bounded.trim()) {
|
||||||
|
throw new AssetError(
|
||||||
|
"INVALID_ORIGINAL_FILENAME",
|
||||||
|
"Uploaded files must include a usable filename",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return bounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMulterError(error, config) {
|
||||||
|
if (!(error instanceof multer.MulterError)) {
|
||||||
|
if (TEMP_STORAGE_ERROR_CODES.has(error?.code)) {
|
||||||
|
return new AssetError(
|
||||||
|
"UPLOAD_TEMP_STORAGE_UNAVAILABLE",
|
||||||
|
"Temporary upload storage is unavailable",
|
||||||
|
{ statusCode: 503, cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new AssetError(
|
||||||
|
"INVALID_MULTIPART_UPLOAD",
|
||||||
|
"The multipart upload could not be read",
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === "LIMIT_FILE_SIZE") {
|
||||||
|
return new AssetError(
|
||||||
|
"UPLOAD_FILE_TOO_LARGE",
|
||||||
|
"An uploaded file exceeds the configured size limit",
|
||||||
|
{
|
||||||
|
statusCode: 413,
|
||||||
|
details: { maxFileBytes: config.maxFileBytes },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error.code === "LIMIT_FILE_COUNT") {
|
||||||
|
return new AssetError(
|
||||||
|
"UPLOAD_FILE_COUNT_EXCEEDED",
|
||||||
|
"The upload contains too many files",
|
||||||
|
{
|
||||||
|
statusCode: 413,
|
||||||
|
details: { maxFiles: config.maxFiles },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"LIMIT_FIELD_COUNT",
|
||||||
|
"LIMIT_FIELD_KEY",
|
||||||
|
"LIMIT_FIELD_VALUE",
|
||||||
|
"LIMIT_PART_COUNT",
|
||||||
|
"LIMIT_UNEXPECTED_FILE",
|
||||||
|
].includes(error.code)
|
||||||
|
) {
|
||||||
|
return new AssetError(
|
||||||
|
"INVALID_MULTIPART_UPLOAD",
|
||||||
|
"The multipart upload contains too many or invalid parts",
|
||||||
|
{ details: { reason: error.code } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AssetError(
|
||||||
|
"INVALID_MULTIPART_UPLOAD",
|
||||||
|
"The multipart upload could not be read",
|
||||||
|
{ details: { reason: error.code } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTemporaryStorage(tempDirectory) {
|
||||||
|
return multer.diskStorage({
|
||||||
|
destination(request, file, callback) {
|
||||||
|
fs.mkdir(tempDirectory, { recursive: true, mode: 0o700 }, (error) => {
|
||||||
|
callback(error, tempDirectory);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
filename(request, file, callback) {
|
||||||
|
const temporaryName = crypto.randomUUID();
|
||||||
|
if (!request[TEMPORARY_UPLOAD_PATHS]) {
|
||||||
|
request[TEMPORARY_UPLOAD_PATHS] = [];
|
||||||
|
}
|
||||||
|
request[TEMPORARY_UPLOAD_PATHS].push(
|
||||||
|
path.join(tempDirectory, temporaryName),
|
||||||
|
);
|
||||||
|
callback(null, temporaryName);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function temporaryUploadFiles(request) {
|
||||||
|
const paths = new Set(request[TEMPORARY_UPLOAD_PATHS] || []);
|
||||||
|
for (const file of Array.isArray(request.files) ? request.files : []) {
|
||||||
|
if (file?.path) paths.add(file.path);
|
||||||
|
}
|
||||||
|
return [...paths].map((temporaryPath) => ({ path: temporaryPath }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMultipartUpload(config) {
|
||||||
|
const middleware = multer({
|
||||||
|
storage: createTemporaryStorage(config.tempDirectory),
|
||||||
|
limits: {
|
||||||
|
fileSize: config.maxFileBytes,
|
||||||
|
files: config.maxFiles,
|
||||||
|
fields: 2,
|
||||||
|
parts: config.maxFiles + 2,
|
||||||
|
fieldNameSize: 64,
|
||||||
|
fieldSize: MAX_FIELD_BYTES,
|
||||||
|
headerPairs: 100,
|
||||||
|
},
|
||||||
|
}).any();
|
||||||
|
|
||||||
|
return function receiveAssetUpload(request, response) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
middleware(request, response, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(mapMulterError(error, config));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.isArray(request.files) ? request.files : [];
|
||||||
|
try {
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new AssetError(
|
||||||
|
"UPLOAD_FILE_REQUIRED",
|
||||||
|
"At least one image file is required",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unexpectedFile = files.find(
|
||||||
|
(file) => !ALLOWED_FILE_FIELDS.has(file.fieldname),
|
||||||
|
);
|
||||||
|
if (unexpectedFile) {
|
||||||
|
throw new AssetError(
|
||||||
|
"UNEXPECTED_UPLOAD_FIELD",
|
||||||
|
"Image files must use the file or files multipart field",
|
||||||
|
{ details: { field: unexpectedFile.fieldname } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body || {};
|
||||||
|
const unknownFields = Object.keys(body).filter(
|
||||||
|
(name) => !ALLOWED_TEXT_FIELDS.has(name),
|
||||||
|
);
|
||||||
|
if (unknownFields.length > 0) {
|
||||||
|
throw new AssetError(
|
||||||
|
"UNEXPECTED_UPLOAD_FIELD",
|
||||||
|
"The multipart upload contains unsupported fields",
|
||||||
|
{ details: { fields: unknownFields } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.hasOwn(body, "display_name") &&
|
||||||
|
Object.hasOwn(body, "displayName")
|
||||||
|
) {
|
||||||
|
throw new AssetError(
|
||||||
|
"DUPLICATE_DISPLAY_NAME_FIELD",
|
||||||
|
"Provide only one display name field",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = body.display_name ?? body.displayName ?? null;
|
||||||
|
if (displayName !== null && files.length !== 1) {
|
||||||
|
throw new AssetError(
|
||||||
|
"DISPLAY_NAME_REQUIRES_SINGLE_FILE",
|
||||||
|
"An explicit display name can be used only with one uploaded file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
displayName,
|
||||||
|
files: files.map((file) => ({
|
||||||
|
path: file.path,
|
||||||
|
byteSize: file.size,
|
||||||
|
originalFilename: safeOriginalFilename(file.originalname),
|
||||||
|
suppliedContentType: file.mimetype,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (validationError) {
|
||||||
|
reject(validationError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTemporaryFiles(files = []) {
|
||||||
|
await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
if (!file?.path) return;
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(file.path, { force: true });
|
||||||
|
} catch {
|
||||||
|
// Temporary cleanup is best-effort and must not mask the API result.
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cleanupTemporaryFiles,
|
||||||
|
createMultipartUpload,
|
||||||
|
mapMulterError,
|
||||||
|
safeOriginalFilename,
|
||||||
|
temporaryUploadFiles,
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const {
|
||||||
|
cleanupTemporaryFiles,
|
||||||
|
createMultipartUpload,
|
||||||
|
temporaryUploadFiles,
|
||||||
|
} = require("./multipart");
|
||||||
|
const { sendOriginal } = require("./streaming");
|
||||||
|
|
||||||
|
function createAssetRouter({
|
||||||
|
assetService,
|
||||||
|
onBeforePlaybackDomainChange = () => {},
|
||||||
|
onPlaylistsChanged = () => {},
|
||||||
|
onPlaybackDomainChanged = () => {},
|
||||||
|
processingConfig,
|
||||||
|
}) {
|
||||||
|
const router = express.Router();
|
||||||
|
const receiveUpload = createMultipartUpload(processingConfig);
|
||||||
|
|
||||||
|
router.get("/", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json({ assets: assetService.list() });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", async (request, response, next) => {
|
||||||
|
const cleanupAfterAbort = () => {
|
||||||
|
void cleanupTemporaryFiles(temporaryUploadFiles(request));
|
||||||
|
};
|
||||||
|
request.once("aborted", cleanupAfterAbort);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upload = await receiveUpload(request, response);
|
||||||
|
const results = await assetService.upload(upload.files, {
|
||||||
|
displayName: upload.displayName,
|
||||||
|
});
|
||||||
|
const createdCount = results.filter((result) => result.created).length;
|
||||||
|
|
||||||
|
if (results.length === 1) {
|
||||||
|
response.status(createdCount > 0 ? 201 : 200).json(results[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(createdCount > 0 ? 201 : 200).json({
|
||||||
|
results,
|
||||||
|
createdCount,
|
||||||
|
duplicateCount: results.length - createdCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
} finally {
|
||||||
|
request.off("aborted", cleanupAfterAbort);
|
||||||
|
await cleanupTemporaryFiles(temporaryUploadFiles(request));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:assetId/references", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(assetService.references(request.params.assetId));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:assetId/original", async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const descriptor = assetService.originalDescriptor(request.params.assetId);
|
||||||
|
await sendOriginal(assetService, response, descriptor);
|
||||||
|
} catch (error) {
|
||||||
|
if (response.headersSent) {
|
||||||
|
response.destroy(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:assetId", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(assetService.get(request.params.assetId));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/:assetId", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const previous = assetService.get(request.params.assetId);
|
||||||
|
const playlistIds = [
|
||||||
|
...new Set(
|
||||||
|
assetService
|
||||||
|
.references(request.params.assetId)
|
||||||
|
.playlists.map((reference) => reference.playlistId),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
onBeforePlaybackDomainChange({
|
||||||
|
reason: "before_asset_update",
|
||||||
|
assetId: previous.id,
|
||||||
|
});
|
||||||
|
const updated = assetService.update(request.params.assetId, request.body);
|
||||||
|
if (updated.updatedAt !== previous.updatedAt) {
|
||||||
|
const change = {
|
||||||
|
reason: "asset_updated",
|
||||||
|
playlistIds,
|
||||||
|
assetId: updated.id,
|
||||||
|
...(updated.expirationAt !== previous.expirationAt
|
||||||
|
? { invalidBoundaryChanged: true }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
onPlaybackDomainChanged(change);
|
||||||
|
if (playlistIds.length > 0) {
|
||||||
|
const publicChange = { ...change };
|
||||||
|
delete publicChange.invalidBoundaryChanged;
|
||||||
|
onPlaylistsChanged(publicChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:assetId", async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(await assetService.remove(request.params.assetId));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createAssetRouter,
|
||||||
|
};
|
||||||
@@ -0,0 +1,851 @@
|
|||||||
|
const crypto = require("node:crypto");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { AssetError, assetNotFound } = require("./errors");
|
||||||
|
const { StorageObjectNotFoundError } = require("../storage");
|
||||||
|
|
||||||
|
const MAX_DISPLAY_NAME_LENGTH = 200;
|
||||||
|
const MAX_PROCESSING_ERROR_LENGTH = 1_000;
|
||||||
|
const CONCURRENT_UPLOAD_WAIT_MS = 15_000;
|
||||||
|
const CONCURRENT_UPLOAD_POLL_MS = 50;
|
||||||
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
function delay(milliseconds) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayName(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new AssetError(
|
||||||
|
"INVALID_ASSET_DISPLAY_NAME",
|
||||||
|
"Asset display name must be a string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value
|
||||||
|
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new AssetError(
|
||||||
|
"INVALID_ASSET_DISPLAY_NAME",
|
||||||
|
"Asset display name cannot be empty",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ([...normalized].length > MAX_DISPLAY_NAME_LENGTH) {
|
||||||
|
throw new AssetError(
|
||||||
|
"INVALID_ASSET_DISPLAY_NAME",
|
||||||
|
`Asset display name cannot exceed ${MAX_DISPLAY_NAME_LENGTH} characters`,
|
||||||
|
{ details: { maxLength: MAX_DISPLAY_NAME_LENGTH } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDisplayName(originalFilename) {
|
||||||
|
const extension = path.extname(originalFilename);
|
||||||
|
const withoutExtension = extension
|
||||||
|
? originalFilename.slice(0, -extension.length)
|
||||||
|
: originalFilename;
|
||||||
|
return normalizeDisplayName(withoutExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExpiration(value) {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (Number.isSafeInteger(value) && value >= 0) return value;
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (Number.isSafeInteger(timestamp) && timestamp >= 0) {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AssetError(
|
||||||
|
"INVALID_ASSET_EXPIRATION",
|
||||||
|
"Asset expiration must be null, an epoch-millisecond integer, or an ISO date",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaUrls(row) {
|
||||||
|
if (row.status !== "ready") {
|
||||||
|
return {
|
||||||
|
originalUrl: null,
|
||||||
|
displayUrl: null,
|
||||||
|
thumbnailUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
originalUrl: `/api/assets/${row.id}/original`,
|
||||||
|
displayUrl: `/media/assets/${row.id}/display/${row.display_sha256}.webp`,
|
||||||
|
thumbnailUrl: `/media/assets/${row.id}/thumbnail/${row.thumbnail_sha256}.webp`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAsset(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
const urls = mediaUrls(row);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
displayName: row.display_name,
|
||||||
|
originalFilename: row.original_filename,
|
||||||
|
status: row.status,
|
||||||
|
processingError: row.processing_error || null,
|
||||||
|
sourceSha256: row.source_sha256,
|
||||||
|
expirationAt: row.expiration_at ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
referenceCount: Number(row.reference_count || 0),
|
||||||
|
original: row.original_content_type
|
||||||
|
? {
|
||||||
|
contentType: row.original_content_type,
|
||||||
|
byteSize: row.original_byte_size,
|
||||||
|
width: row.original_width,
|
||||||
|
height: row.original_height,
|
||||||
|
url: urls.originalUrl,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
display: row.display_content_type
|
||||||
|
? {
|
||||||
|
sha256: row.display_sha256,
|
||||||
|
contentType: row.display_content_type,
|
||||||
|
byteSize: row.display_byte_size,
|
||||||
|
width: row.display_width,
|
||||||
|
height: row.display_height,
|
||||||
|
url: urls.displayUrl,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
thumbnail: row.thumbnail_content_type
|
||||||
|
? {
|
||||||
|
sha256: row.thumbnail_sha256,
|
||||||
|
contentType: row.thumbnail_content_type,
|
||||||
|
byteSize: row.thumbnail_byte_size,
|
||||||
|
width: row.thumbnail_width,
|
||||||
|
height: row.thumbnail_height,
|
||||||
|
url: urls.thumbnailUrl,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashFile(filename) {
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
let byteSize = 0;
|
||||||
|
for await (const chunk of fs.createReadStream(filename)) {
|
||||||
|
byteSize += chunk.length;
|
||||||
|
hash.update(chunk);
|
||||||
|
}
|
||||||
|
return { sourceSha256: hash.digest("hex"), byteSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectKeys(assetId, processed) {
|
||||||
|
return {
|
||||||
|
original: `assets/${assetId}/original/${processed.original.sha256}`,
|
||||||
|
display: `assets/${assetId}/display/${processed.display.sha256}.webp`,
|
||||||
|
thumbnail: `assets/${assetId}/thumbnail/${processed.thumbnail.sha256}.webp`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedProcessingError(error) {
|
||||||
|
const code = typeof error?.code === "string" ? error.code : "PROCESSING_FAILED";
|
||||||
|
const message =
|
||||||
|
typeof error?.message === "string" && error.message
|
||||||
|
? error.message
|
||||||
|
: "Asset processing failed";
|
||||||
|
return `${code}: ${message}`.slice(0, MAX_PROCESSING_ERROR_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAssetService({
|
||||||
|
database,
|
||||||
|
storage,
|
||||||
|
imageProcessor,
|
||||||
|
now = Date.now,
|
||||||
|
createId = crypto.randomUUID,
|
||||||
|
wait = delay,
|
||||||
|
concurrentUploadWaitMs = CONCURRENT_UPLOAD_WAIT_MS,
|
||||||
|
onProcessingFailed = () => {},
|
||||||
|
}) {
|
||||||
|
const assetSelect = `
|
||||||
|
SELECT a.*,
|
||||||
|
(SELECT COUNT(*) FROM playlist_entries pe WHERE pe.asset_id = a.id)
|
||||||
|
AS reference_count
|
||||||
|
FROM assets a
|
||||||
|
`;
|
||||||
|
const listAssets = database.prepare(`${assetSelect} ORDER BY a.created_at DESC, a.id`);
|
||||||
|
const findById = database.prepare(`${assetSelect} WHERE a.id = ?`);
|
||||||
|
const findByHash = database.prepare(`${assetSelect} WHERE a.source_sha256 = ?`);
|
||||||
|
const findByName = database.prepare(`${assetSelect} WHERE a.display_name = ? COLLATE NOCASE`);
|
||||||
|
const insertProcessing = database.prepare(`
|
||||||
|
INSERT INTO assets (
|
||||||
|
id, source_sha256, display_name, original_filename, status,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @sourceSha256, @displayName, @originalFilename, 'processing',
|
||||||
|
@createdAt, @updatedAt
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const markReady = database.prepare(`
|
||||||
|
UPDATE assets
|
||||||
|
SET status = 'ready',
|
||||||
|
processing_error = NULL,
|
||||||
|
original_object_key = @originalObjectKey,
|
||||||
|
original_content_type = @originalContentType,
|
||||||
|
original_byte_size = @originalByteSize,
|
||||||
|
original_width = @originalWidth,
|
||||||
|
original_height = @originalHeight,
|
||||||
|
display_object_key = @displayObjectKey,
|
||||||
|
display_sha256 = @displaySha256,
|
||||||
|
display_content_type = @displayContentType,
|
||||||
|
display_byte_size = @displayByteSize,
|
||||||
|
display_width = @displayWidth,
|
||||||
|
display_height = @displayHeight,
|
||||||
|
thumbnail_object_key = @thumbnailObjectKey,
|
||||||
|
thumbnail_sha256 = @thumbnailSha256,
|
||||||
|
thumbnail_content_type = @thumbnailContentType,
|
||||||
|
thumbnail_byte_size = @thumbnailByteSize,
|
||||||
|
thumbnail_width = @thumbnailWidth,
|
||||||
|
thumbnail_height = @thumbnailHeight,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
WHERE id = @id AND status = 'processing'
|
||||||
|
`);
|
||||||
|
const markFailed = database.prepare(`
|
||||||
|
UPDATE assets
|
||||||
|
SET status = 'failed',
|
||||||
|
processing_error = @processingError,
|
||||||
|
original_object_key = @originalObjectKey,
|
||||||
|
original_content_type = @originalContentType,
|
||||||
|
original_byte_size = @originalByteSize,
|
||||||
|
original_width = @originalWidth,
|
||||||
|
original_height = @originalHeight,
|
||||||
|
display_object_key = @displayObjectKey,
|
||||||
|
display_sha256 = @displaySha256,
|
||||||
|
display_content_type = @displayContentType,
|
||||||
|
display_byte_size = @displayByteSize,
|
||||||
|
display_width = @displayWidth,
|
||||||
|
display_height = @displayHeight,
|
||||||
|
thumbnail_object_key = @thumbnailObjectKey,
|
||||||
|
thumbnail_sha256 = @thumbnailSha256,
|
||||||
|
thumbnail_content_type = @thumbnailContentType,
|
||||||
|
thumbnail_byte_size = @thumbnailByteSize,
|
||||||
|
thumbnail_width = @thumbnailWidth,
|
||||||
|
thumbnail_height = @thumbnailHeight,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
WHERE id = @id AND status = 'processing'
|
||||||
|
`);
|
||||||
|
const updateEditable = database.prepare(`
|
||||||
|
UPDATE assets
|
||||||
|
SET display_name = @displayName,
|
||||||
|
expiration_at = @expirationAt,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
WHERE id = @id AND status != 'deleting'
|
||||||
|
`);
|
||||||
|
const playlistReferences = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
pe.id AS entry_id,
|
||||||
|
pe.playlist_id,
|
||||||
|
p.name AS playlist_name,
|
||||||
|
pe.enabled,
|
||||||
|
pe.position,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM playback_state active
|
||||||
|
WHERE active.active_playlist_id = pe.playlist_id
|
||||||
|
) AS is_active_playlist,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM playback_state current
|
||||||
|
WHERE current.current_entry_id = pe.id
|
||||||
|
) AS is_current_normal_target
|
||||||
|
FROM playlist_entries pe
|
||||||
|
JOIN playlists p ON p.id = pe.playlist_id
|
||||||
|
WHERE pe.asset_id = ?
|
||||||
|
ORDER BY p.name COLLATE NOCASE, pe.position, pe.id
|
||||||
|
`);
|
||||||
|
const playbackReferences = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
output_id,
|
||||||
|
mode,
|
||||||
|
active_playlist_id,
|
||||||
|
current_entry_id,
|
||||||
|
static_asset_id
|
||||||
|
FROM playback_state
|
||||||
|
WHERE static_asset_id = ?
|
||||||
|
OR current_entry_id IN (
|
||||||
|
SELECT id FROM playlist_entries WHERE asset_id = ?
|
||||||
|
)
|
||||||
|
ORDER BY output_id
|
||||||
|
`);
|
||||||
|
const markDeleting = database.prepare(`
|
||||||
|
UPDATE assets
|
||||||
|
SET status = 'deleting', updated_at = ?
|
||||||
|
WHERE id = ? AND status != 'processing'
|
||||||
|
`);
|
||||||
|
const deleteAssetRow = database.prepare(
|
||||||
|
"DELETE FROM assets WHERE id = ? AND status = 'deleting'",
|
||||||
|
);
|
||||||
|
|
||||||
|
const completeAsset = database.transaction((metadata) => {
|
||||||
|
const result = markReady.run(metadata);
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new Error("Asset processing state changed before completion");
|
||||||
|
}
|
||||||
|
return findById.get(metadata.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getRaw(assetId) {
|
||||||
|
const row = findById.get(assetId);
|
||||||
|
if (!row) throw assetNotFound(assetId);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateStateError(row) {
|
||||||
|
if (row.status === "failed") {
|
||||||
|
return new AssetError(
|
||||||
|
"ASSET_PROCESSING_FAILED",
|
||||||
|
"These bytes match an asset whose processing previously failed",
|
||||||
|
{ statusCode: 409, details: { asset: mapAsset(row) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.status === "deleting") {
|
||||||
|
return new AssetError(
|
||||||
|
"ASSET_DELETING",
|
||||||
|
"These bytes match an asset that is being deleted",
|
||||||
|
{ statusCode: 409, details: { assetId: row.id } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new AssetError(
|
||||||
|
"ASSET_PROCESSING",
|
||||||
|
"These bytes are already being processed",
|
||||||
|
{ statusCode: 409, details: { assetId: row.id } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveExistingHash(row) {
|
||||||
|
if (row.status === "ready") {
|
||||||
|
return { created: false, duplicate: true, asset: mapAsset(row) };
|
||||||
|
}
|
||||||
|
if (row.status !== "processing") {
|
||||||
|
throw duplicateStateError(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = Date.now() + concurrentUploadWaitMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await wait(CONCURRENT_UPLOAD_POLL_MS);
|
||||||
|
const latest = findByHash.get(row.source_sha256);
|
||||||
|
if (!latest) break;
|
||||||
|
if (latest.status === "ready") {
|
||||||
|
return { created: false, duplicate: true, asset: mapAsset(latest) };
|
||||||
|
}
|
||||||
|
if (latest.status !== "processing") {
|
||||||
|
throw duplicateStateError(latest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw duplicateStateError(findByHash.get(row.source_sha256) || row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameConflict(displayName, row = findByName.get(displayName)) {
|
||||||
|
return new AssetError(
|
||||||
|
"ASSET_NAME_CONFLICT",
|
||||||
|
"Another library asset already uses that display name",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
details: row ? { asset: mapAsset(row) } : { displayName },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processingMetadata(id, processed, keys, error) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
processingError: error ? boundedProcessingError(error) : null,
|
||||||
|
originalObjectKey: keys.original,
|
||||||
|
originalContentType: processed.original.contentType,
|
||||||
|
originalByteSize: processed.original.byteSize,
|
||||||
|
originalWidth: processed.original.width,
|
||||||
|
originalHeight: processed.original.height,
|
||||||
|
displayObjectKey: keys.display,
|
||||||
|
displaySha256: processed.display.sha256,
|
||||||
|
displayContentType: processed.display.contentType,
|
||||||
|
displayByteSize: processed.display.byteSize,
|
||||||
|
displayWidth: processed.display.width,
|
||||||
|
displayHeight: processed.display.height,
|
||||||
|
thumbnailObjectKey: keys.thumbnail,
|
||||||
|
thumbnailSha256: processed.thumbnail.sha256,
|
||||||
|
thumbnailContentType: processed.thumbnail.contentType,
|
||||||
|
thumbnailByteSize: processed.thumbnail.byteSize,
|
||||||
|
thumbnailWidth: processed.thumbnail.width,
|
||||||
|
thumbnailHeight: processed.thumbnail.height,
|
||||||
|
updatedAt: now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanObjects(keys) {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
[...new Set(Object.values(keys).filter(Boolean))].map((key) =>
|
||||||
|
storage.deleteObject(key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rejected = results.find((result) => result.status === "rejected");
|
||||||
|
if (rejected) throw rejected.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProcessedAsset(plan) {
|
||||||
|
const id = createId();
|
||||||
|
const timestamp = now();
|
||||||
|
try {
|
||||||
|
insertProcessing.run({
|
||||||
|
id,
|
||||||
|
sourceSha256: plan.sourceSha256,
|
||||||
|
displayName: plan.displayName,
|
||||||
|
originalFilename: plan.file.originalFilename,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const hashMatch = findByHash.get(plan.sourceSha256);
|
||||||
|
if (hashMatch) return resolveExistingHash(hashMatch);
|
||||||
|
const nameMatch = findByName.get(plan.displayName);
|
||||||
|
if (nameMatch) throw nameConflict(plan.displayName, nameMatch);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = objectKeys(id, plan.processed);
|
||||||
|
try {
|
||||||
|
await storage.putObject({
|
||||||
|
key: keys.original,
|
||||||
|
body: fs.createReadStream(plan.file.path),
|
||||||
|
contentType: plan.processed.original.contentType,
|
||||||
|
contentLength: plan.processed.original.byteSize,
|
||||||
|
metadata: { sha256: plan.processed.original.sha256 },
|
||||||
|
});
|
||||||
|
await storage.putObject({
|
||||||
|
key: keys.display,
|
||||||
|
body: plan.processed.display.buffer,
|
||||||
|
contentType: plan.processed.display.contentType,
|
||||||
|
contentLength: plan.processed.display.byteSize,
|
||||||
|
metadata: { sha256: plan.processed.display.sha256 },
|
||||||
|
});
|
||||||
|
await storage.putObject({
|
||||||
|
key: keys.thumbnail,
|
||||||
|
body: plan.processed.thumbnail.buffer,
|
||||||
|
contentType: plan.processed.thumbnail.contentType,
|
||||||
|
contentLength: plan.processed.thumbnail.byteSize,
|
||||||
|
metadata: { sha256: plan.processed.thumbnail.sha256 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = completeAsset(processingMetadata(id, plan.processed, keys));
|
||||||
|
return { created: true, duplicate: false, asset: mapAsset(row) };
|
||||||
|
} catch (error) {
|
||||||
|
const failure = processingMetadata(id, plan.processed, keys, error);
|
||||||
|
let recorded = false;
|
||||||
|
try {
|
||||||
|
markFailed.run(failure);
|
||||||
|
recorded = true;
|
||||||
|
} catch {
|
||||||
|
// Preserve the original failure; the processing row remains diagnosable.
|
||||||
|
}
|
||||||
|
if (recorded) {
|
||||||
|
try {
|
||||||
|
// One observable transition per newly failed asset. No polling scan
|
||||||
|
// is needed to notice historical failures.
|
||||||
|
onProcessingFailed({
|
||||||
|
assetId: id,
|
||||||
|
displayName: plan.displayName || plan.file?.originalFilename || null,
|
||||||
|
message: failure.processingError,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A notification hook must never mask the original upload error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await cleanObjects(keys);
|
||||||
|
} catch {
|
||||||
|
// The failed row retains deterministic keys so DELETE can retry cleanup.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error?.statusCode) {
|
||||||
|
error.details = { ...(error.details || {}), assetId: id, retryable: true };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(files, { displayName = null } = {}) {
|
||||||
|
if (!Array.isArray(files) || files.length === 0) {
|
||||||
|
throw new AssetError(
|
||||||
|
"UPLOAD_FILE_REQUIRED",
|
||||||
|
"At least one image file is required",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plans = [];
|
||||||
|
const firstPlanByHash = new Map();
|
||||||
|
const plannedNames = new Map();
|
||||||
|
|
||||||
|
for (const [index, file] of files.entries()) {
|
||||||
|
const identity = await hashFile(file.path);
|
||||||
|
if (identity.byteSize !== file.byteSize) {
|
||||||
|
throw new AssetError(
|
||||||
|
"UPLOAD_CHANGED_DURING_PROCESSING",
|
||||||
|
"An uploaded temporary file changed before processing",
|
||||||
|
{ statusCode: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = findByHash.get(identity.sourceSha256);
|
||||||
|
if (existing) {
|
||||||
|
plans.push({ index, existing });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorPlan = firstPlanByHash.get(identity.sourceSha256);
|
||||||
|
if (priorPlan) {
|
||||||
|
plans.push({ index, duplicateOf: priorPlan });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposedName = normalizeDisplayName(
|
||||||
|
displayName ?? defaultDisplayName(file.originalFilename),
|
||||||
|
);
|
||||||
|
const existingName = findByName.get(proposedName);
|
||||||
|
if (existingName) throw nameConflict(proposedName, existingName);
|
||||||
|
|
||||||
|
const normalizedNameKey = proposedName.toLowerCase();
|
||||||
|
const batchNameMatch = plannedNames.get(normalizedNameKey);
|
||||||
|
if (batchNameMatch) throw nameConflict(proposedName);
|
||||||
|
|
||||||
|
const plan = {
|
||||||
|
index,
|
||||||
|
file,
|
||||||
|
displayName: proposedName,
|
||||||
|
sourceSha256: identity.sourceSha256,
|
||||||
|
};
|
||||||
|
firstPlanByHash.set(identity.sourceSha256, plan);
|
||||||
|
plannedNames.set(normalizedNameKey, plan);
|
||||||
|
plans.push(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode every new image before any database or object-storage mutation.
|
||||||
|
for (const plan of plans) {
|
||||||
|
if (plan.existing || plan.duplicateOf) continue;
|
||||||
|
plan.processed = await imageProcessor.process(plan.file.path);
|
||||||
|
if (plan.processed.original.sha256 !== plan.sourceSha256) {
|
||||||
|
throw new AssetError(
|
||||||
|
"UPLOAD_CHANGED_DURING_PROCESSING",
|
||||||
|
"An uploaded temporary file changed before processing",
|
||||||
|
{ statusCode: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultByPlan = new Map();
|
||||||
|
const results = new Array(files.length);
|
||||||
|
for (const plan of plans) {
|
||||||
|
let result;
|
||||||
|
if (plan.existing) {
|
||||||
|
result = await resolveExistingHash(plan.existing);
|
||||||
|
} else if (plan.duplicateOf) {
|
||||||
|
const originalResult = resultByPlan.get(plan.duplicateOf);
|
||||||
|
result = {
|
||||||
|
created: false,
|
||||||
|
duplicate: true,
|
||||||
|
asset: originalResult.asset,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
result = await createProcessedAsset(plan);
|
||||||
|
resultByPlan.set(plan, result);
|
||||||
|
}
|
||||||
|
results[plan.index] = result;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
return listAssets.all().map(mapAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(assetId) {
|
||||||
|
return mapAsset(getRaw(assetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(assetId, input) {
|
||||||
|
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
throw new AssetError("INVALID_ASSET_UPDATE", "Asset update must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set([
|
||||||
|
"displayName",
|
||||||
|
"display_name",
|
||||||
|
"expirationAt",
|
||||||
|
"expiration_at",
|
||||||
|
]);
|
||||||
|
const unknown = Object.keys(input).filter((key) => !allowed.has(key));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
throw new AssetError(
|
||||||
|
"INVALID_ASSET_UPDATE",
|
||||||
|
"Asset update contains unsupported fields",
|
||||||
|
{ details: { fields: unknown } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Object.keys(input).length === 0) {
|
||||||
|
throw new AssetError("INVALID_ASSET_UPDATE", "At least one field is required");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Object.hasOwn(input, "displayName") &&
|
||||||
|
Object.hasOwn(input, "display_name")
|
||||||
|
) {
|
||||||
|
throw new AssetError("INVALID_ASSET_UPDATE", "Display name was provided twice");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Object.hasOwn(input, "expirationAt") &&
|
||||||
|
Object.hasOwn(input, "expiration_at")
|
||||||
|
) {
|
||||||
|
throw new AssetError("INVALID_ASSET_UPDATE", "Expiration was provided twice");
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = getRaw(assetId);
|
||||||
|
if (current.status === "deleting") {
|
||||||
|
throw new AssetError("ASSET_DELETING", "Deleting assets cannot be edited", {
|
||||||
|
statusCode: 409,
|
||||||
|
details: { assetId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hasDisplayName =
|
||||||
|
Object.hasOwn(input, "displayName") || Object.hasOwn(input, "display_name");
|
||||||
|
const hasExpiration =
|
||||||
|
Object.hasOwn(input, "expirationAt") || Object.hasOwn(input, "expiration_at");
|
||||||
|
const nextDisplayName = hasDisplayName
|
||||||
|
? normalizeDisplayName(
|
||||||
|
Object.hasOwn(input, "displayName")
|
||||||
|
? input.displayName
|
||||||
|
: input.display_name,
|
||||||
|
)
|
||||||
|
: current.display_name;
|
||||||
|
const nextExpiration = hasExpiration
|
||||||
|
? normalizeExpiration(
|
||||||
|
Object.hasOwn(input, "expirationAt")
|
||||||
|
? input.expirationAt
|
||||||
|
: input.expiration_at,
|
||||||
|
)
|
||||||
|
: current.expiration_at;
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextDisplayName === current.display_name &&
|
||||||
|
nextExpiration === current.expiration_at
|
||||||
|
) {
|
||||||
|
return mapAsset(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = updateEditable.run({
|
||||||
|
id: assetId,
|
||||||
|
displayName: nextDisplayName,
|
||||||
|
expirationAt: nextExpiration,
|
||||||
|
updatedAt: Math.max(now(), current.updated_at + 1),
|
||||||
|
});
|
||||||
|
if (result.changes !== 1) {
|
||||||
|
throw new AssetError("ASSET_DELETING", "Deleting assets cannot be edited", {
|
||||||
|
statusCode: 409,
|
||||||
|
details: { assetId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const nameMatch = findByName.get(nextDisplayName);
|
||||||
|
if (nameMatch && nameMatch.id !== assetId) {
|
||||||
|
throw nameConflict(nextDisplayName, nameMatch);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return mapAsset(getRaw(assetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function references(assetId) {
|
||||||
|
const asset = getRaw(assetId);
|
||||||
|
const playlists = playlistReferences.all(assetId).map((row) => ({
|
||||||
|
playlistId: row.playlist_id,
|
||||||
|
playlistName: row.playlist_name,
|
||||||
|
playlistEntryId: row.entry_id,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
position: row.position,
|
||||||
|
isActivePlaylist: Boolean(row.is_active_playlist),
|
||||||
|
isCurrentNormalTarget: Boolean(row.is_current_normal_target),
|
||||||
|
}));
|
||||||
|
const playback = playbackReferences.all(assetId, assetId).map((row) => ({
|
||||||
|
outputId: row.output_id,
|
||||||
|
mode: row.mode,
|
||||||
|
activePlaylistId: row.active_playlist_id,
|
||||||
|
currentPlaylistEntryId: row.current_entry_id,
|
||||||
|
isCurrentNormalTarget: playlists.some(
|
||||||
|
(entry) => entry.playlistEntryId === row.current_entry_id,
|
||||||
|
),
|
||||||
|
isCurrentStaticTarget: row.static_asset_id === assetId,
|
||||||
|
}));
|
||||||
|
const currentNormalTarget = playback.some((state) => state.isCurrentNormalTarget);
|
||||||
|
const currentStaticTarget = playback.some((state) => state.isCurrentStaticTarget);
|
||||||
|
const blockerCodes = [];
|
||||||
|
if (playlists.length > 0) blockerCodes.push("PLAYLIST_REFERENCES");
|
||||||
|
if (currentNormalTarget) blockerCodes.push("CURRENT_PLAYBACK_TARGET");
|
||||||
|
if (currentStaticTarget) blockerCodes.push("CURRENT_STATIC_TARGET");
|
||||||
|
if (asset.status === "processing") blockerCodes.push("ASSET_PROCESSING");
|
||||||
|
if (asset.status === "deleting") blockerCodes.push("ASSET_DELETING");
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetId,
|
||||||
|
playlists,
|
||||||
|
playback,
|
||||||
|
currentNormalTarget,
|
||||||
|
currentStaticTarget,
|
||||||
|
canDelete: blockerCodes.length === 0,
|
||||||
|
blockerCodes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginDelete = database.transaction((assetId) => {
|
||||||
|
const asset = getRaw(assetId);
|
||||||
|
const referenceInfo = references(assetId);
|
||||||
|
const relationshipBlockers = referenceInfo.blockerCodes.filter((code) =>
|
||||||
|
[
|
||||||
|
"PLAYLIST_REFERENCES",
|
||||||
|
"CURRENT_PLAYBACK_TARGET",
|
||||||
|
"CURRENT_STATIC_TARGET",
|
||||||
|
].includes(code),
|
||||||
|
);
|
||||||
|
if (relationshipBlockers.length > 0) {
|
||||||
|
throw new AssetError(
|
||||||
|
referenceInfo.playlists.length > 0 ? "ASSET_REFERENCED" : "ASSET_IN_USE",
|
||||||
|
"The library asset is still referenced and cannot be deleted",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
details: { ...referenceInfo, blockerCodes: relationshipBlockers },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (asset.status === "processing") {
|
||||||
|
throw new AssetError(
|
||||||
|
"ASSET_PROCESSING",
|
||||||
|
"An asset cannot be deleted while it is processing",
|
||||||
|
{ statusCode: 409, details: { assetId } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (asset.status !== "deleting") {
|
||||||
|
markDeleting.run(Math.max(now(), asset.updated_at + 1), assetId);
|
||||||
|
}
|
||||||
|
return findById.get(assetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function remove(assetId) {
|
||||||
|
const deleting = beginDelete(assetId);
|
||||||
|
const keys = {
|
||||||
|
original: deleting.original_object_key,
|
||||||
|
display: deleting.display_object_key,
|
||||||
|
thumbnail: deleting.thumbnail_object_key,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cleanObjects(keys);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.statusCode) {
|
||||||
|
error.details = { ...(error.details || {}), assetId, retryable: true };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = deleteAssetRow.run(assetId);
|
||||||
|
if (result.changes !== 1) throw assetNotFound(assetId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code?.startsWith?.("SQLITE_CONSTRAINT")) {
|
||||||
|
throw new AssetError(
|
||||||
|
"ASSET_REFERENCED",
|
||||||
|
"The library asset became referenced before deletion completed",
|
||||||
|
{ statusCode: 409, details: { assetId, retryable: true } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { id: assetId, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaDescriptor(assetId, kind, requestedHash) {
|
||||||
|
if (!SHA256_PATTERN.test(requestedHash)) {
|
||||||
|
throw new AssetError("MEDIA_NOT_FOUND", "Asset media version not found", {
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const asset = getRaw(assetId);
|
||||||
|
const fields =
|
||||||
|
kind === "display"
|
||||||
|
? {
|
||||||
|
hash: asset.display_sha256,
|
||||||
|
key: asset.display_object_key,
|
||||||
|
contentType: asset.display_content_type,
|
||||||
|
contentLength: asset.display_byte_size,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
hash: asset.thumbnail_sha256,
|
||||||
|
key: asset.thumbnail_object_key,
|
||||||
|
contentType: asset.thumbnail_content_type,
|
||||||
|
contentLength: asset.thumbnail_byte_size,
|
||||||
|
};
|
||||||
|
if (asset.status !== "ready" || fields.hash !== requestedHash || !fields.key) {
|
||||||
|
throw new AssetError("MEDIA_NOT_FOUND", "Asset media version not found", {
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { asset, kind, ...fields };
|
||||||
|
}
|
||||||
|
|
||||||
|
function originalDescriptor(assetId) {
|
||||||
|
const asset = getRaw(assetId);
|
||||||
|
if (asset.status !== "ready" || !asset.original_object_key) {
|
||||||
|
throw new AssetError("ASSET_ORIGINAL_NOT_READY", "Asset original is not ready", {
|
||||||
|
statusCode: 409,
|
||||||
|
details: { assetId, status: asset.status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
asset,
|
||||||
|
key: asset.original_object_key,
|
||||||
|
hash: asset.source_sha256,
|
||||||
|
contentType: asset.original_content_type,
|
||||||
|
contentLength: asset.original_byte_size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStoredObject(descriptor) {
|
||||||
|
try {
|
||||||
|
return await storage.getObject(descriptor.key);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof StorageObjectNotFoundError) {
|
||||||
|
throw new AssetError(
|
||||||
|
"STORAGE_INCONSISTENCY",
|
||||||
|
"Asset metadata exists but its stored bytes are unavailable",
|
||||||
|
{
|
||||||
|
statusCode: 503,
|
||||||
|
details: { assetId: descriptor.asset.id, retryable: true },
|
||||||
|
cause: error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
list,
|
||||||
|
get,
|
||||||
|
upload,
|
||||||
|
update,
|
||||||
|
references,
|
||||||
|
remove,
|
||||||
|
mediaDescriptor,
|
||||||
|
originalDescriptor,
|
||||||
|
readStoredObject,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createAssetService,
|
||||||
|
defaultDisplayName,
|
||||||
|
mapAsset,
|
||||||
|
normalizeDisplayName,
|
||||||
|
normalizeExpiration,
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const { Readable } = require("node:stream");
|
||||||
|
const { pipeline } = require("node:stream/promises");
|
||||||
|
const { AssetError } = require("./errors");
|
||||||
|
|
||||||
|
function readableBody(body) {
|
||||||
|
if (body && typeof body.pipe === "function") return body;
|
||||||
|
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
|
||||||
|
return Readable.from([body]);
|
||||||
|
}
|
||||||
|
if (body && typeof body.getReader === "function") {
|
||||||
|
return Readable.fromWeb(body);
|
||||||
|
}
|
||||||
|
throw new AssetError(
|
||||||
|
"STORAGE_INCONSISTENCY",
|
||||||
|
"Stored asset bytes could not be streamed",
|
||||||
|
{ statusCode: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDescriptor(descriptor) {
|
||||||
|
if (
|
||||||
|
typeof descriptor.contentType !== "string" ||
|
||||||
|
!Number.isSafeInteger(descriptor.contentLength) ||
|
||||||
|
descriptor.contentLength < 0
|
||||||
|
) {
|
||||||
|
throw new AssetError(
|
||||||
|
"STORAGE_INCONSISTENCY",
|
||||||
|
"Stored asset metadata is incomplete",
|
||||||
|
{ statusCode: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCommonHeaders(response, descriptor) {
|
||||||
|
response.set("Content-Type", descriptor.contentType);
|
||||||
|
response.set("Content-Length", String(descriptor.contentLength));
|
||||||
|
response.set("X-Content-Type-Options", "nosniff");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storedBody(assetService, descriptor) {
|
||||||
|
const stored = await assetService.readStoredObject(descriptor);
|
||||||
|
return readableBody(stored.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendImmutableMedia(assetService, response, descriptor) {
|
||||||
|
validateDescriptor(descriptor);
|
||||||
|
const body = await storedBody(assetService, descriptor);
|
||||||
|
setCommonHeaders(response, descriptor);
|
||||||
|
response.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
response.set("ETag", `"${descriptor.hash}"`);
|
||||||
|
await pipeline(body, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOriginal(assetService, response, descriptor) {
|
||||||
|
validateDescriptor(descriptor);
|
||||||
|
const body = await storedBody(assetService, descriptor);
|
||||||
|
response.attachment(descriptor.asset.original_filename);
|
||||||
|
setCommonHeaders(response, descriptor);
|
||||||
|
response.set("Cache-Control", "private, no-store");
|
||||||
|
await pipeline(body, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendImmutableMedia,
|
||||||
|
sendOriginal,
|
||||||
|
};
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
class ConfigError extends Error {
|
||||||
|
constructor(message, field) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ConfigError";
|
||||||
|
this.field = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(env, name) {
|
||||||
|
const value = env[name]?.trim();
|
||||||
|
if (!value) {
|
||||||
|
throw new ConfigError(`${name} is required`, name);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(env, name) {
|
||||||
|
const value = env[name]?.trim();
|
||||||
|
return value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(env, name, defaultValue, { min, max } = {}) {
|
||||||
|
const raw = env[name];
|
||||||
|
if (raw === undefined || raw === "") {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^-?\d+$/.test(raw)) {
|
||||||
|
throw new ConfigError(`${name} must be an integer`, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isSafeInteger(value)) {
|
||||||
|
throw new ConfigError(`${name} must be a safe integer`, name);
|
||||||
|
}
|
||||||
|
if (min !== undefined && value < min) {
|
||||||
|
throw new ConfigError(`${name} must be at least ${min}`, name);
|
||||||
|
}
|
||||||
|
if (max !== undefined && value > max) {
|
||||||
|
throw new ConfigError(`${name} must be at most ${max}`, name);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolean(env, name, defaultValue) {
|
||||||
|
const raw = env[name];
|
||||||
|
if (raw === undefined || raw === "") {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (["true", "1", "yes", "on"].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (["false", "0", "no", "off"].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new ConfigError(`${name} must be a boolean`, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function url(env, name, { required = false, protocols } = {}) {
|
||||||
|
const raw = required ? requiredString(env, name) : optionalString(env, name);
|
||||||
|
if (raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new ConfigError(`${name} must be a valid URL`, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (protocols && !protocols.includes(parsed.protocol)) {
|
||||||
|
throw new ConfigError(
|
||||||
|
`${name} must use one of these protocols: ${protocols.join(", ")}`,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed.toString().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicPrefix(env) {
|
||||||
|
const value = env.MQTT_TOPIC_PREFIX?.trim() || "announcements";
|
||||||
|
if (/[+#]/.test(value)) {
|
||||||
|
throw new ConfigError(
|
||||||
|
"MQTT_TOPIC_PREFIX cannot contain the + or # wildcards",
|
||||||
|
"MQTT_TOPIC_PREFIX",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value.startsWith("/") || value.endsWith("/")) {
|
||||||
|
throw new ConfigError(
|
||||||
|
"MQTT_TOPIC_PREFIX cannot start or end with /",
|
||||||
|
"MQTT_TOPIC_PREFIX",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketName(env) {
|
||||||
|
const value = requiredString(env, "S3_BUCKET");
|
||||||
|
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)) {
|
||||||
|
throw new ConfigError(
|
||||||
|
"S3_BUCKET must be a valid lowercase S3 bucket name",
|
||||||
|
"S3_BUCKET",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeEnvironment(env) {
|
||||||
|
const value = env.NODE_ENV?.trim() || "development";
|
||||||
|
if (!["development", "test", "production"].includes(value)) {
|
||||||
|
throw new ConfigError(
|
||||||
|
"NODE_ENV must be development, test, or production",
|
||||||
|
"NODE_ENV",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeZone(env) {
|
||||||
|
const value = requiredString(env, "APP_TIMEZONE");
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
|
||||||
|
} catch {
|
||||||
|
throw new ConfigError("APP_TIMEZONE must be a valid IANA time zone", "APP_TIMEZONE");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageCredential(env, name, environment) {
|
||||||
|
const value = optionalString(env, name);
|
||||||
|
if (environment === "production" && value === null) {
|
||||||
|
throw new ConfigError(`${name} is required in production`, name);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfig(env = process.env) {
|
||||||
|
const environment = nodeEnvironment(env);
|
||||||
|
const databasePath = requiredString(env, "DATABASE_PATH");
|
||||||
|
if (databasePath !== ":memory:" && !path.isAbsolute(databasePath)) {
|
||||||
|
throw new ConfigError("DATABASE_PATH must be an absolute path", "DATABASE_PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
environment,
|
||||||
|
host: env.HOST?.trim() || "0.0.0.0",
|
||||||
|
port: integer(env, "PORT", 7532, { min: 0, max: 65535 }),
|
||||||
|
timeZone: timeZone(env),
|
||||||
|
shutdownTimeoutMs: integer(env, "SHUTDOWN_TIMEOUT_MS", 10_000, {
|
||||||
|
min: 1_000,
|
||||||
|
max: 60_000,
|
||||||
|
}),
|
||||||
|
trustProxy: boolean(env, "TRUST_PROXY", true),
|
||||||
|
database: Object.freeze({
|
||||||
|
path: databasePath,
|
||||||
|
busyTimeoutMs: integer(env, "DATABASE_BUSY_TIMEOUT_MS", 5_000, {
|
||||||
|
min: 0,
|
||||||
|
max: 60_000,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
storage: Object.freeze({
|
||||||
|
endpoint: url(env, "S3_ENDPOINT", {
|
||||||
|
required: true,
|
||||||
|
protocols: ["http:", "https:"],
|
||||||
|
}),
|
||||||
|
region: env.S3_REGION?.trim() || "us-east-1",
|
||||||
|
bucket: bucketName(env),
|
||||||
|
accessKeyId: storageCredential(env, "S3_ACCESS_KEY_ID", environment),
|
||||||
|
secretAccessKey: storageCredential(env, "S3_SECRET_ACCESS_KEY", environment),
|
||||||
|
forcePathStyle: boolean(env, "S3_FORCE_PATH_STYLE", true),
|
||||||
|
healthProbeIntervalMs: integer(
|
||||||
|
env,
|
||||||
|
"S3_HEALTH_PROBE_INTERVAL_MS",
|
||||||
|
30_000,
|
||||||
|
{ min: 1_000, max: 300_000 },
|
||||||
|
),
|
||||||
|
connectionTimeoutMs: integer(env, "S3_CONNECT_TIMEOUT_MS", 2_000, {
|
||||||
|
min: 250,
|
||||||
|
max: 60_000,
|
||||||
|
}),
|
||||||
|
requestTimeoutMs: integer(env, "S3_REQUEST_TIMEOUT_MS", 5_000, {
|
||||||
|
min: 1_000,
|
||||||
|
max: 120_000,
|
||||||
|
}),
|
||||||
|
maxAttempts: integer(env, "S3_MAX_ATTEMPTS", 2, { min: 1, max: 5 }),
|
||||||
|
}),
|
||||||
|
processing: Object.freeze({
|
||||||
|
tempDirectory: (() => {
|
||||||
|
const value = env.UPLOAD_TEMP_DIRECTORY?.trim() || "/tmp/announcements-uploads";
|
||||||
|
if (!path.isAbsolute(value)) {
|
||||||
|
throw new ConfigError(
|
||||||
|
"UPLOAD_TEMP_DIRECTORY must be an absolute path",
|
||||||
|
"UPLOAD_TEMP_DIRECTORY",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})(),
|
||||||
|
maxFileBytes: integer(env, "UPLOAD_MAX_FILE_BYTES", 25 * 1024 * 1024, {
|
||||||
|
min: 1,
|
||||||
|
max: 1024 * 1024 * 1024,
|
||||||
|
}),
|
||||||
|
maxFiles: integer(env, "UPLOAD_MAX_FILES", 20, { min: 1, max: 100 }),
|
||||||
|
maxInputPixels: integer(env, "IMAGE_MAX_INPUT_PIXELS", 40_000_000, {
|
||||||
|
min: 1,
|
||||||
|
max: 500_000_000,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
mqtt: Object.freeze({
|
||||||
|
url: url(env, "MQTT_URL", {
|
||||||
|
protocols: ["mqtt:", "mqtts:", "ws:", "wss:"],
|
||||||
|
}),
|
||||||
|
username: optionalString(env, "MQTT_USERNAME"),
|
||||||
|
password: optionalString(env, "MQTT_PASSWORD"),
|
||||||
|
topicPrefix: topicPrefix(env),
|
||||||
|
reconnectPeriodMs: integer(env, "MQTT_RECONNECT_PERIOD_MS", 5_000, {
|
||||||
|
min: 250,
|
||||||
|
max: 300_000,
|
||||||
|
}),
|
||||||
|
connectTimeoutMs: integer(env, "MQTT_CONNECT_TIMEOUT_MS", 10_000, {
|
||||||
|
min: 250,
|
||||||
|
max: 120_000,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
notifications: Object.freeze({
|
||||||
|
homeAssistantWebhookUrl: url(env, "HA_WEBHOOK_URL", {
|
||||||
|
protocols: ["http:", "https:"],
|
||||||
|
}),
|
||||||
|
homeAssistantWebhookSecret: optionalString(env, "HA_WEBHOOK_SECRET"),
|
||||||
|
webhookTimeoutMs: integer(env, "HA_WEBHOOK_TIMEOUT_MS", 10_000, {
|
||||||
|
min: 250,
|
||||||
|
max: 120_000,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
displays: Object.freeze({
|
||||||
|
offlineThresholdMs: integer(env, "DISPLAY_OFFLINE_THRESHOLD_MS", 45_000, {
|
||||||
|
min: 5_000,
|
||||||
|
max: 3_600_000,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactedConfig(config) {
|
||||||
|
return {
|
||||||
|
environment: config.environment,
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
timeZone: config.timeZone,
|
||||||
|
trustProxy: config.trustProxy,
|
||||||
|
database: {
|
||||||
|
configured: Boolean(config.database.path),
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
configured: Boolean(
|
||||||
|
config.storage.endpoint &&
|
||||||
|
config.storage.bucket &&
|
||||||
|
config.storage.accessKeyId &&
|
||||||
|
config.storage.secretAccessKey,
|
||||||
|
),
|
||||||
|
region: config.storage.region,
|
||||||
|
bucket: config.storage.bucket,
|
||||||
|
forcePathStyle: config.storage.forcePathStyle,
|
||||||
|
healthProbeIntervalMs: config.storage.healthProbeIntervalMs,
|
||||||
|
connectionTimeoutMs: config.storage.connectionTimeoutMs,
|
||||||
|
requestTimeoutMs: config.storage.requestTimeoutMs,
|
||||||
|
maxAttempts: config.storage.maxAttempts,
|
||||||
|
},
|
||||||
|
processing: {
|
||||||
|
tempDirectoryConfigured: Boolean(config.processing.tempDirectory),
|
||||||
|
maxFileBytes: config.processing.maxFileBytes,
|
||||||
|
maxFiles: config.processing.maxFiles,
|
||||||
|
maxInputPixels: config.processing.maxInputPixels,
|
||||||
|
},
|
||||||
|
// Status output must survive a partial or unexpected configuration shape:
|
||||||
|
// this mapping feeds the endpoints an operator uses when things are wrong.
|
||||||
|
mqtt: {
|
||||||
|
configured: Boolean(config.mqtt?.url),
|
||||||
|
topicPrefix: config.mqtt?.topicPrefix ?? null,
|
||||||
|
reconnectPeriodMs: config.mqtt?.reconnectPeriodMs ?? null,
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
configured: Boolean(config.notifications?.homeAssistantWebhookUrl),
|
||||||
|
webhookTimeoutMs: config.notifications?.webhookTimeoutMs ?? null,
|
||||||
|
},
|
||||||
|
displays: {
|
||||||
|
offlineThresholdMs: config.displays?.offlineThresholdMs ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ConfigError,
|
||||||
|
loadConfig,
|
||||||
|
redactedConfig,
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
|
||||||
|
function openDatabase({ path: filename, busyTimeoutMs = 5_000 }) {
|
||||||
|
if (filename !== ":memory:") {
|
||||||
|
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = new Database(filename, {
|
||||||
|
timeout: busyTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
database.pragma("foreign_keys = ON");
|
||||||
|
database.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
||||||
|
if (filename !== ":memory:") {
|
||||||
|
database.pragma("journal_mode = WAL");
|
||||||
|
database.pragma("synchronous = NORMAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDatabase(database) {
|
||||||
|
database.prepare("SELECT 1 AS ok").get();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkDatabase,
|
||||||
|
openDatabase,
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const DEFAULT_MIGRATIONS_DIRECTORY = path.join(__dirname, "migrations");
|
||||||
|
const MIGRATION_FILENAME = /^(\d+)_([a-z0-9_-]+)\.sql$/i;
|
||||||
|
|
||||||
|
function discoverMigrations(directory = DEFAULT_MIGRATIONS_DIRECTORY) {
|
||||||
|
const migrations = fs
|
||||||
|
.readdirSync(directory, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
|
||||||
|
.map((entry) => {
|
||||||
|
const match = MIGRATION_FILENAME.exec(entry.name);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid migration filename: ${entry.name}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: Number(match[1]),
|
||||||
|
name: match[2],
|
||||||
|
filename: entry.name,
|
||||||
|
sql: fs.readFileSync(path.join(directory, entry.name), "utf8"),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.version - right.version);
|
||||||
|
|
||||||
|
const versions = new Set();
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (!Number.isSafeInteger(migration.version) || migration.version < 1) {
|
||||||
|
throw new Error(`Invalid migration version: ${migration.filename}`);
|
||||||
|
}
|
||||||
|
if (versions.has(migration.version)) {
|
||||||
|
throw new Error(`Duplicate migration version: ${migration.version}`);
|
||||||
|
}
|
||||||
|
versions.add(migration.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMigrationsTable(database) {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAppliedMigrations(database) {
|
||||||
|
return database
|
||||||
|
.prepare(
|
||||||
|
`SELECT version, name, applied_at AS appliedAt
|
||||||
|
FROM schema_migrations
|
||||||
|
ORDER BY version`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationStatus(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
|
||||||
|
ensureMigrationsTable(database);
|
||||||
|
const available = discoverMigrations(directory);
|
||||||
|
const applied = readAppliedMigrations(database);
|
||||||
|
const appliedVersions = new Set(applied.map((migration) => migration.version));
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentVersion: applied.at(-1)?.version ?? 0,
|
||||||
|
latestAvailableVersion: available.at(-1)?.version ?? 0,
|
||||||
|
pendingVersions: available
|
||||||
|
.filter((migration) => !appliedVersions.has(migration.version))
|
||||||
|
.map((migration) => migration.version),
|
||||||
|
applied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMigrations(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
|
||||||
|
ensureMigrationsTable(database);
|
||||||
|
const migrations = discoverMigrations(directory);
|
||||||
|
const applied = new Map(
|
||||||
|
readAppliedMigrations(database).map((migration) => [
|
||||||
|
migration.version,
|
||||||
|
migration.name,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const availableVersions = new Set(migrations.map((migration) => migration.version));
|
||||||
|
|
||||||
|
for (const [version] of applied) {
|
||||||
|
if (!availableVersions.has(version)) {
|
||||||
|
throw new Error(`Database contains unknown migration version ${version}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertMigration = database.prepare(
|
||||||
|
`INSERT INTO schema_migrations (version, name, applied_at)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
);
|
||||||
|
const applyMigration = database.transaction((migration) => {
|
||||||
|
database.exec(migration.sql);
|
||||||
|
insertMigration.run(migration.version, migration.name, Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
const appliedName = applied.get(migration.version);
|
||||||
|
if (appliedName !== undefined) {
|
||||||
|
if (appliedName !== migration.name) {
|
||||||
|
throw new Error(
|
||||||
|
`Migration ${migration.version} was applied as ${appliedName}, not ${migration.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
applyMigration(migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrationStatus(database, directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_MIGRATIONS_DIRECTORY,
|
||||||
|
discoverMigrations,
|
||||||
|
migrationStatus,
|
||||||
|
runMigrations,
|
||||||
|
};
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
CREATE TABLE assets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_sha256 TEXT NOT NULL UNIQUE
|
||||||
|
CHECK (length(source_sha256) = 64),
|
||||||
|
display_name TEXT NOT NULL COLLATE NOCASE UNIQUE
|
||||||
|
CHECK (length(trim(display_name)) > 0),
|
||||||
|
original_filename TEXT NOT NULL
|
||||||
|
CHECK (length(trim(original_filename)) > 0),
|
||||||
|
status TEXT NOT NULL
|
||||||
|
CHECK (status IN ('processing', 'ready', 'failed', 'deleting')),
|
||||||
|
processing_error TEXT,
|
||||||
|
expiration_at INTEGER,
|
||||||
|
|
||||||
|
original_object_key TEXT,
|
||||||
|
original_content_type TEXT,
|
||||||
|
original_byte_size INTEGER CHECK (original_byte_size IS NULL OR original_byte_size >= 0),
|
||||||
|
original_width INTEGER CHECK (original_width IS NULL OR original_width > 0),
|
||||||
|
original_height INTEGER CHECK (original_height IS NULL OR original_height > 0),
|
||||||
|
|
||||||
|
display_object_key TEXT,
|
||||||
|
display_sha256 TEXT CHECK (display_sha256 IS NULL OR length(display_sha256) = 64),
|
||||||
|
display_content_type TEXT,
|
||||||
|
display_byte_size INTEGER CHECK (display_byte_size IS NULL OR display_byte_size >= 0),
|
||||||
|
display_width INTEGER CHECK (display_width IS NULL OR display_width > 0),
|
||||||
|
display_height INTEGER CHECK (display_height IS NULL OR display_height > 0),
|
||||||
|
|
||||||
|
thumbnail_object_key TEXT,
|
||||||
|
thumbnail_sha256 TEXT CHECK (thumbnail_sha256 IS NULL OR length(thumbnail_sha256) = 64),
|
||||||
|
thumbnail_content_type TEXT,
|
||||||
|
thumbnail_byte_size INTEGER CHECK (thumbnail_byte_size IS NULL OR thumbnail_byte_size >= 0),
|
||||||
|
thumbnail_width INTEGER CHECK (thumbnail_width IS NULL OR thumbnail_width > 0),
|
||||||
|
thumbnail_height INTEGER CHECK (thumbnail_height IS NULL OR thumbnail_height > 0),
|
||||||
|
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX assets_lifecycle_idx ON assets (status, expiration_at);
|
||||||
|
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL COLLATE NOCASE UNIQUE
|
||||||
|
CHECK (length(trim(name)) > 0),
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE playlist_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
asset_id TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||||
|
duration_override_ms INTEGER
|
||||||
|
CHECK (duration_override_ms IS NULL OR duration_override_ms > 0),
|
||||||
|
background_mode_override TEXT
|
||||||
|
CHECK (
|
||||||
|
background_mode_override IS NULL OR
|
||||||
|
background_mode_override IN ('blurred', 'solid', 'black')
|
||||||
|
),
|
||||||
|
background_color_override TEXT,
|
||||||
|
background_darkened_override INTEGER
|
||||||
|
CHECK (
|
||||||
|
background_darkened_override IS NULL OR
|
||||||
|
background_darkened_override IN (0, 1)
|
||||||
|
),
|
||||||
|
schedule_start_at INTEGER,
|
||||||
|
schedule_end_at INTEGER,
|
||||||
|
position INTEGER NOT NULL CHECK (position >= 0),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE (playlist_id, position),
|
||||||
|
UNIQUE (playlist_id, asset_id),
|
||||||
|
CHECK (
|
||||||
|
schedule_start_at IS NULL OR
|
||||||
|
schedule_end_at IS NULL OR
|
||||||
|
schedule_start_at < schedule_end_at
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX playlist_entries_asset_idx ON playlist_entries (asset_id);
|
||||||
|
CREATE INDEX playlist_entries_schedule_idx
|
||||||
|
ON playlist_entries (playlist_id, enabled, schedule_start_at, schedule_end_at);
|
||||||
|
|
||||||
|
CREATE TABLE app_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||||
|
default_duration_ms INTEGER NOT NULL DEFAULT 7000
|
||||||
|
CHECK (default_duration_ms > 0),
|
||||||
|
default_background_mode TEXT NOT NULL DEFAULT 'blurred'
|
||||||
|
CHECK (default_background_mode IN ('blurred', 'solid', 'black')),
|
||||||
|
default_solid_background_color TEXT NOT NULL DEFAULT '#000000'
|
||||||
|
CHECK (length(trim(default_solid_background_color)) > 0),
|
||||||
|
default_background_darkened INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK (default_background_darkened IN (0, 1)),
|
||||||
|
default_blur_px INTEGER NOT NULL DEFAULT 20
|
||||||
|
CHECK (default_blur_px >= 0),
|
||||||
|
crossfade_duration_ms INTEGER NOT NULL DEFAULT 1000
|
||||||
|
CHECK (crossfade_duration_ms >= 0),
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO app_settings (id, updated_at)
|
||||||
|
VALUES (1, CAST(strftime('%s', 'now') AS INTEGER) * 1000);
|
||||||
|
|
||||||
|
CREATE TABLE playback_state (
|
||||||
|
output_id TEXT PRIMARY KEY,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'on' CHECK (mode IN ('on', 'off')),
|
||||||
|
active_playlist_id TEXT,
|
||||||
|
pending_playlist_id TEXT,
|
||||||
|
pending_playlist_requested_at INTEGER,
|
||||||
|
current_entry_id TEXT,
|
||||||
|
rotation_paused INTEGER NOT NULL DEFAULT 0 CHECK (rotation_paused IN (0, 1)),
|
||||||
|
paused_remaining_ms INTEGER
|
||||||
|
CHECK (paused_remaining_ms IS NULL OR paused_remaining_ms >= 0),
|
||||||
|
static_asset_id TEXT,
|
||||||
|
slide_started_at INTEGER,
|
||||||
|
next_slide_at INTEGER,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (active_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (pending_playlist_id) REFERENCES playlists(id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (current_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (static_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
|
||||||
|
CHECK (
|
||||||
|
(pending_playlist_id IS NULL AND pending_playlist_requested_at IS NULL) OR
|
||||||
|
(pending_playlist_id IS NOT NULL AND pending_playlist_requested_at IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO playback_state (output_id, updated_at)
|
||||||
|
VALUES ('global', CAST(strftime('%s', 'now') AS INTEGER) * 1000);
|
||||||
|
|
||||||
|
CREATE TABLE asset_import_sources (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_system TEXT NOT NULL,
|
||||||
|
source_bucket TEXT NOT NULL,
|
||||||
|
source_key TEXT NOT NULL,
|
||||||
|
source_etag TEXT,
|
||||||
|
source_byte_size INTEGER CHECK (source_byte_size IS NULL OR source_byte_size >= 0),
|
||||||
|
source_sha256 TEXT CHECK (source_sha256 IS NULL OR length(source_sha256) = 64),
|
||||||
|
asset_id TEXT,
|
||||||
|
initial_playlist_entry_id TEXT,
|
||||||
|
status TEXT NOT NULL
|
||||||
|
CHECK (status IN ('pending', 'processing', 'imported', 'failed')),
|
||||||
|
error TEXT,
|
||||||
|
imported_at INTEGER,
|
||||||
|
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (initial_playlist_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
|
||||||
|
UNIQUE (source_system, source_bucket, source_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX asset_import_sources_status_idx ON asset_import_sources (status);
|
||||||
|
|
||||||
|
CREATE TABLE notification_incidents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
condition_key TEXT NOT NULL,
|
||||||
|
episode INTEGER NOT NULL CHECK (episode > 0),
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('open', 'recovered')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
context_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
opened_at INTEGER NOT NULL,
|
||||||
|
last_observed_at INTEGER NOT NULL,
|
||||||
|
recovered_at INTEGER,
|
||||||
|
UNIQUE (condition_key, episode),
|
||||||
|
CHECK (
|
||||||
|
(state = 'open' AND recovered_at IS NULL) OR
|
||||||
|
(state = 'recovered' AND recovered_at IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX notification_incidents_one_open_idx
|
||||||
|
ON notification_incidents (condition_key)
|
||||||
|
WHERE state = 'open';
|
||||||
|
|
||||||
|
CREATE TABLE notification_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
incident_id TEXT NOT NULL,
|
||||||
|
transition TEXT NOT NULL CHECK (transition IN ('failure', 'recovery')),
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||||
|
next_attempt_at INTEGER NOT NULL,
|
||||||
|
delivered_at INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (incident_id) REFERENCES notification_incidents(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (incident_id, transition)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX notification_outbox_due_idx
|
||||||
|
ON notification_outbox (next_attempt_at)
|
||||||
|
WHERE delivered_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE display_clients (
|
||||||
|
client_id TEXT PRIMARY KEY,
|
||||||
|
output_id TEXT NOT NULL DEFAULT 'global',
|
||||||
|
first_seen_at INTEGER NOT NULL,
|
||||||
|
last_seen_at INTEGER NOT NULL,
|
||||||
|
active_playlist_id TEXT,
|
||||||
|
playlist_revision INTEGER,
|
||||||
|
playback_revision INTEGER,
|
||||||
|
current_entry_id TEXT,
|
||||||
|
current_asset_id TEXT,
|
||||||
|
cached_asset_count INTEGER CHECK (cached_asset_count IS NULL OR cached_asset_count >= 0),
|
||||||
|
playable_asset_count INTEGER CHECK (playable_asset_count IS NULL OR playable_asset_count >= 0),
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX display_clients_last_seen_idx ON display_clients (last_seen_at);
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { checkDatabase } = require("../database");
|
||||||
|
const { redactedConfig } = require("../config");
|
||||||
|
|
||||||
|
function databaseHealth(database) {
|
||||||
|
try {
|
||||||
|
checkDatabase(database);
|
||||||
|
return { status: "ok" };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
message: "SQLite is unavailable",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageHealth(storage) {
|
||||||
|
if (!storage) {
|
||||||
|
return {
|
||||||
|
status: "degraded",
|
||||||
|
checkedAt: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
code: "STORAGE_NOT_INITIALIZED",
|
||||||
|
message: "Object storage has not been initialized",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = storage.health();
|
||||||
|
if (result?.status === "healthy") {
|
||||||
|
return {
|
||||||
|
status: "healthy",
|
||||||
|
checkedAt: result.checkedAt || null,
|
||||||
|
lastSuccessAt: result.lastSuccessAt || result.checkedAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "degraded",
|
||||||
|
checkedAt: result?.checkedAt || null,
|
||||||
|
lastSuccessAt: result?.lastSuccessAt || null,
|
||||||
|
code:
|
||||||
|
result?.code === "STORAGE_NOT_CHECKED"
|
||||||
|
? "STORAGE_NOT_CHECKED"
|
||||||
|
: "STORAGE_UNAVAILABLE",
|
||||||
|
message:
|
||||||
|
result?.code === "STORAGE_NOT_CHECKED"
|
||||||
|
? "Object storage has not been checked"
|
||||||
|
: "Object storage is unavailable",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// A health endpoint must remain available when its dependency is not.
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "degraded",
|
||||||
|
checkedAt: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
code: "STORAGE_UNAVAILABLE",
|
||||||
|
message: "Object storage is unavailable",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHealthRouter({ database, lifecycle, migrations, storage }) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/live", (request, response) => {
|
||||||
|
response.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/ready", async (request, response) => {
|
||||||
|
const databaseCheck = databaseHealth(database);
|
||||||
|
const storageCheck = storageHealth(storage);
|
||||||
|
const migrationsCurrent = migrations.pendingVersions.length === 0;
|
||||||
|
const ready =
|
||||||
|
lifecycle.ready && databaseCheck.status === "ok" && migrationsCurrent;
|
||||||
|
|
||||||
|
response.status(ready ? 200 : 503).json({
|
||||||
|
status: ready ? "ready" : "not_ready",
|
||||||
|
checks: {
|
||||||
|
database: databaseCheck.status,
|
||||||
|
migrations: migrationsCurrent ? "current" : "pending",
|
||||||
|
storage: storageCheck.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration status is reported defensively: an integration that is down or
|
||||||
|
// throwing must still produce a status document rather than a 500.
|
||||||
|
function integrationStatus(service, fallbackConfigured) {
|
||||||
|
if (!service) {
|
||||||
|
return { status: "not_started", configured: fallbackConfigured };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return service.status();
|
||||||
|
} catch {
|
||||||
|
return { status: "unknown", configured: fallbackConfigured };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playbackStatus(playbackService) {
|
||||||
|
if (!playbackService) return { status: "not_started" };
|
||||||
|
try {
|
||||||
|
const snapshot = playbackService.getSnapshot();
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
mode: snapshot.mode,
|
||||||
|
transportState: snapshot.transportState,
|
||||||
|
activePlaylistId: snapshot.activePlaylistId ?? null,
|
||||||
|
playbackRevision: snapshot.playbackRevision,
|
||||||
|
hasPlayableTarget: Boolean(snapshot.normalTarget),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { status: "unknown" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDetailedHealthRouter({
|
||||||
|
config,
|
||||||
|
database,
|
||||||
|
displayService,
|
||||||
|
integrations = {},
|
||||||
|
lifecycle,
|
||||||
|
migrations,
|
||||||
|
playbackService,
|
||||||
|
storage,
|
||||||
|
}) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", async (request, response) => {
|
||||||
|
const databaseCheck = databaseHealth(database);
|
||||||
|
const storageCheck = storageHealth(storage);
|
||||||
|
const ready =
|
||||||
|
lifecycle.ready &&
|
||||||
|
databaseCheck.status === "ok" &&
|
||||||
|
migrations.pendingVersions.length === 0;
|
||||||
|
|
||||||
|
const configuration = redactedConfig(config);
|
||||||
|
const mqttCheck = integrationStatus(
|
||||||
|
integrations.mqtt,
|
||||||
|
configuration.mqtt.configured,
|
||||||
|
);
|
||||||
|
const notificationCheck = integrationStatus(
|
||||||
|
integrations.notifications,
|
||||||
|
configuration.notifications.configured,
|
||||||
|
);
|
||||||
|
const displayCheck = integrationStatus(displayService, true);
|
||||||
|
// External integrations degrade the reported status. They never make the
|
||||||
|
// API unready, because signage playback does not depend on them.
|
||||||
|
const degraded =
|
||||||
|
storageCheck.status !== "healthy" ||
|
||||||
|
mqttCheck.status === "disconnected" ||
|
||||||
|
notificationCheck.status === "degraded";
|
||||||
|
const status = !ready ? "not_ready" : degraded ? "degraded" : "ok";
|
||||||
|
|
||||||
|
response.status(ready ? 200 : 503).json({
|
||||||
|
status,
|
||||||
|
configuration,
|
||||||
|
services: {
|
||||||
|
database: {
|
||||||
|
...databaseCheck,
|
||||||
|
currentSchemaVersion: migrations.currentVersion,
|
||||||
|
latestSchemaVersion: migrations.latestAvailableVersion,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
...storageCheck,
|
||||||
|
configured: configuration.storage.configured,
|
||||||
|
},
|
||||||
|
mqtt: mqttCheck,
|
||||||
|
notifications: notificationCheck,
|
||||||
|
playback: playbackStatus(playbackService),
|
||||||
|
displays: displayCheck,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createDetailedHealthRouter,
|
||||||
|
createHealthRouter,
|
||||||
|
databaseHealth,
|
||||||
|
storageHealth,
|
||||||
|
};
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
const crypto = require("node:crypto");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const sharp = require("sharp");
|
||||||
|
|
||||||
|
const SUPPORTED_FORMATS = Object.freeze(new Set(["jpeg", "png", "webp"]));
|
||||||
|
const CONTENT_TYPES = Object.freeze({
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
png: "image/png",
|
||||||
|
webp: "image/webp",
|
||||||
|
});
|
||||||
|
const BLACK = Object.freeze({ r: 0, g: 0, b: 0, alpha: 1 });
|
||||||
|
const DEFAULT_IMAGE_DERIVATIVE_OPTIONS = Object.freeze({
|
||||||
|
display: Object.freeze({ maxWidth: 1920, maxHeight: 1080, quality: 82 }),
|
||||||
|
thumbnail: Object.freeze({ maxWidth: 480, maxHeight: 270, quality: 72 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
class ImageProcessingError extends Error {
|
||||||
|
constructor(code, message, { statusCode = 422, details, cause } = {}) {
|
||||||
|
super(message, cause ? { cause } : undefined);
|
||||||
|
this.name = "ImageProcessingError";
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
if (details !== undefined) {
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireInteger(value, name, { min, max } = {}) {
|
||||||
|
if (!Number.isSafeInteger(value)) {
|
||||||
|
throw new TypeError(`${name} must be an integer`);
|
||||||
|
}
|
||||||
|
if (min !== undefined && value < min) {
|
||||||
|
throw new RangeError(`${name} must be at least ${min}`);
|
||||||
|
}
|
||||||
|
if (max !== undefined && value > max) {
|
||||||
|
throw new RangeError(`${name} must be at most ${max}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTargetOptions(target, name, defaults) {
|
||||||
|
if (
|
||||||
|
target !== undefined &&
|
||||||
|
(target === null || typeof target !== "object" || Array.isArray(target))
|
||||||
|
) {
|
||||||
|
throw new TypeError(`${name} image options must be an object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = { ...defaults, ...target };
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
maxWidth: requireInteger(configured.maxWidth, `${name}.maxWidth`, { min: 1 }),
|
||||||
|
maxHeight: requireInteger(configured.maxHeight, `${name}.maxHeight`, { min: 1 }),
|
||||||
|
quality: requireInteger(configured.quality, `${name}.quality`, {
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(options) {
|
||||||
|
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
||||||
|
throw new TypeError("Image processing options are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
maxInputPixels: requireInteger(
|
||||||
|
options.maxInputPixels,
|
||||||
|
"maxInputPixels",
|
||||||
|
{ min: 1 },
|
||||||
|
),
|
||||||
|
display: normalizeTargetOptions(
|
||||||
|
options.display,
|
||||||
|
"display",
|
||||||
|
DEFAULT_IMAGE_DERIVATIVE_OPTIONS.display,
|
||||||
|
),
|
||||||
|
thumbnail: normalizeTargetOptions(
|
||||||
|
options.thumbnail,
|
||||||
|
"thumbnail",
|
||||||
|
DEFAULT_IMAGE_DERIVATIVE_OPTIONS.thumbnail,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(buffer) {
|
||||||
|
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashFile(filename) {
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
let byteSize = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunk of fs.createReadStream(filename)) {
|
||||||
|
byteSize += chunk.length;
|
||||||
|
hash.update(chunk);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new ImageProcessingError(
|
||||||
|
"IMAGE_INPUT_UNREADABLE",
|
||||||
|
"The uploaded image could not be read",
|
||||||
|
{ statusCode: 422, cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { byteSize, sha256: hash.digest("hex") };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeInput(input) {
|
||||||
|
if (Buffer.isBuffer(input)) {
|
||||||
|
return {
|
||||||
|
sharpInput: input,
|
||||||
|
byteSize: input.length,
|
||||||
|
sha256: sha256(input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input !== "string" || input.length === 0) {
|
||||||
|
throw new ImageProcessingError(
|
||||||
|
"INVALID_IMAGE_INPUT",
|
||||||
|
"Image input must be a Buffer or temporary file path",
|
||||||
|
{ statusCode: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = await fs.promises.stat(input);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ImageProcessingError(
|
||||||
|
"IMAGE_INPUT_UNREADABLE",
|
||||||
|
"The uploaded image could not be read",
|
||||||
|
{ statusCode: 422, cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
throw new ImageProcessingError(
|
||||||
|
"INVALID_IMAGE_INPUT",
|
||||||
|
"Image input path must identify a regular file",
|
||||||
|
{ statusCode: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sharpInput: input,
|
||||||
|
...(await hashFile(input)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeError(error, maxInputPixels) {
|
||||||
|
if (error instanceof ImageProcessingError) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/pixel limit/i.test(error?.message || "")) {
|
||||||
|
return new ImageProcessingError(
|
||||||
|
"IMAGE_PIXEL_LIMIT_EXCEEDED",
|
||||||
|
"The uploaded image exceeds the configured decoded-pixel limit",
|
||||||
|
{
|
||||||
|
statusCode: 413,
|
||||||
|
details: { maxInputPixels },
|
||||||
|
cause: error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ImageProcessingError(
|
||||||
|
"MALFORMED_IMAGE",
|
||||||
|
"The uploaded file is not a valid decodable image",
|
||||||
|
{ statusCode: 422, cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMetadata(input, maxInputPixels) {
|
||||||
|
try {
|
||||||
|
return await sharp(input, {
|
||||||
|
animated: true,
|
||||||
|
failOn: "error",
|
||||||
|
limitInputPixels: maxInputPixels,
|
||||||
|
}).metadata();
|
||||||
|
} catch (error) {
|
||||||
|
throw decodeError(error, maxInputPixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMetadata(metadata) {
|
||||||
|
const pages = metadata.pages ?? 1;
|
||||||
|
|
||||||
|
if (pages > 1) {
|
||||||
|
const hasAnimationTiming =
|
||||||
|
metadata.loop !== undefined ||
|
||||||
|
(Array.isArray(metadata.delay) && metadata.delay.length > 0);
|
||||||
|
throw new ImageProcessingError(
|
||||||
|
hasAnimationTiming
|
||||||
|
? "ANIMATED_IMAGE_NOT_SUPPORTED"
|
||||||
|
: "MULTIPAGE_IMAGE_NOT_SUPPORTED",
|
||||||
|
hasAnimationTiming
|
||||||
|
? "Animated images are not supported"
|
||||||
|
: "Multipage images are not supported",
|
||||||
|
{ statusCode: 422, details: { pages } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SUPPORTED_FORMATS.has(metadata.format)) {
|
||||||
|
throw new ImageProcessingError(
|
||||||
|
"UNSUPPORTED_IMAGE_FORMAT",
|
||||||
|
"Only still JPEG, PNG, and WebP images are supported",
|
||||||
|
{
|
||||||
|
statusCode: 415,
|
||||||
|
details: {
|
||||||
|
detectedFormat: metadata.format || null,
|
||||||
|
supportedFormats: [...SUPPORTED_FORMATS],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(metadata.width) ||
|
||||||
|
metadata.width < 1 ||
|
||||||
|
!Number.isSafeInteger(metadata.height) ||
|
||||||
|
metadata.height < 1
|
||||||
|
) {
|
||||||
|
throw new ImageProcessingError(
|
||||||
|
"MALFORMED_IMAGE",
|
||||||
|
"The uploaded image does not contain valid dimensions",
|
||||||
|
{ statusCode: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateDerivative(input, target, maxInputPixels) {
|
||||||
|
let output;
|
||||||
|
try {
|
||||||
|
output = await sharp(input, {
|
||||||
|
failOn: "error",
|
||||||
|
limitInputPixels: maxInputPixels,
|
||||||
|
})
|
||||||
|
.autoOrient()
|
||||||
|
.flatten({ background: BLACK })
|
||||||
|
.resize({
|
||||||
|
width: target.maxWidth,
|
||||||
|
height: target.maxHeight,
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: true,
|
||||||
|
})
|
||||||
|
.toColourspace("srgb")
|
||||||
|
.webp({ quality: target.quality, effort: 4 })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
} catch (error) {
|
||||||
|
throw decodeError(error, maxInputPixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: output.data,
|
||||||
|
sha256: sha256(output.data),
|
||||||
|
format: "webp",
|
||||||
|
contentType: CONTENT_TYPES.webp,
|
||||||
|
byteSize: output.data.length,
|
||||||
|
width: output.info.width,
|
||||||
|
height: output.info.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function originalMetadata(metadata, inputDescription) {
|
||||||
|
const orientedDimensions = metadata.autoOrient || {
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sha256: inputDescription.sha256,
|
||||||
|
format: metadata.format,
|
||||||
|
contentType: CONTENT_TYPES[metadata.format],
|
||||||
|
byteSize: inputDescription.byteSize,
|
||||||
|
// Width and height describe how the original should appear after applying
|
||||||
|
// its source orientation. The encoded dimensions remain available for
|
||||||
|
// diagnostics without implying that the archived bytes were rewritten.
|
||||||
|
width: orientedDimensions.width,
|
||||||
|
height: orientedDimensions.height,
|
||||||
|
encodedWidth: metadata.width,
|
||||||
|
encodedHeight: metadata.height,
|
||||||
|
orientation: metadata.orientation ?? null,
|
||||||
|
pages: metadata.pages ?? 1,
|
||||||
|
channels: metadata.channels,
|
||||||
|
hasAlpha: Boolean(metadata.hasAlpha),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createImageProcessor(options) {
|
||||||
|
const config = normalizeOptions(options);
|
||||||
|
|
||||||
|
async function process(input) {
|
||||||
|
const inputDescription = await describeInput(input);
|
||||||
|
const metadata = await readMetadata(
|
||||||
|
inputDescription.sharpInput,
|
||||||
|
config.maxInputPixels,
|
||||||
|
);
|
||||||
|
validateMetadata(metadata);
|
||||||
|
|
||||||
|
const [display, thumbnail] = await Promise.all([
|
||||||
|
generateDerivative(
|
||||||
|
inputDescription.sharpInput,
|
||||||
|
config.display,
|
||||||
|
config.maxInputPixels,
|
||||||
|
),
|
||||||
|
generateDerivative(
|
||||||
|
inputDescription.sharpInput,
|
||||||
|
config.thumbnail,
|
||||||
|
config.maxInputPixels,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
original: originalMetadata(metadata, inputDescription),
|
||||||
|
display,
|
||||||
|
thumbnail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ process });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_IMAGE_DERIVATIVE_OPTIONS,
|
||||||
|
ImageProcessingError,
|
||||||
|
createImageProcessor,
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const path = require("node:path");
|
||||||
|
require("dotenv").config({ path: path.resolve(__dirname, "../../.env") });
|
||||||
|
|
||||||
|
const { loadConfig, redactedConfig } = require("./config");
|
||||||
|
const { createRuntime } = require("./runtime");
|
||||||
|
|
||||||
|
async function startApplication({ env = process.env, logger = console } = {}) {
|
||||||
|
const config = loadConfig(env);
|
||||||
|
const runtime = createRuntime({ config, logger });
|
||||||
|
const address = await runtime.start();
|
||||||
|
const displayedHost = address.family === "IPv6" ? `[${address.address}]` : address.address;
|
||||||
|
|
||||||
|
logger.info?.(
|
||||||
|
`Announcements API listening on http://${displayedHost}:${address.port}`,
|
||||||
|
);
|
||||||
|
logger.info?.("Configuration loaded", redactedConfig(config));
|
||||||
|
|
||||||
|
let shutdownStarted = false;
|
||||||
|
const shutdown = async (signal) => {
|
||||||
|
if (shutdownStarted) return;
|
||||||
|
shutdownStarted = true;
|
||||||
|
try {
|
||||||
|
await runtime.stop(signal);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error?.("Graceful shutdown failed", error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.once("SIGTERM", shutdown);
|
||||||
|
process.once("SIGINT", shutdown);
|
||||||
|
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
await startApplication();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
startApplication,
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
const ELIGIBILITY_REASONS = Object.freeze({
|
||||||
|
DISABLED: "disabled",
|
||||||
|
SCHEDULED_FUTURE: "scheduled_future",
|
||||||
|
SCHEDULED_ENDED: "scheduled_ended",
|
||||||
|
ASSET_NOT_READY: "asset_not_ready",
|
||||||
|
ASSET_EXPIRED: "asset_expired",
|
||||||
|
});
|
||||||
|
|
||||||
|
function read(object, snakeCaseName, camelCaseName) {
|
||||||
|
if (object && Object.hasOwn(object, snakeCaseName)) {
|
||||||
|
return object[snakeCaseName];
|
||||||
|
}
|
||||||
|
return object?.[camelCaseName];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNow(nowMs) {
|
||||||
|
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
|
||||||
|
throw new TypeError("nowMs must be a non-negative, safe epoch-millisecond integer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateEntryEligibility(entry, asset, nowMs) {
|
||||||
|
validateNow(nowMs);
|
||||||
|
|
||||||
|
const enabled = read(entry, "enabled", "enabled");
|
||||||
|
if (enabled !== true && enabled !== 1) {
|
||||||
|
return { playable: false, reason: ELIGIBILITY_REASONS.DISABLED };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleStartAt = read(entry, "schedule_start_at", "scheduleStartAt");
|
||||||
|
if (scheduleStartAt != null && nowMs < scheduleStartAt) {
|
||||||
|
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_FUTURE };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleEndAt = read(entry, "schedule_end_at", "scheduleEndAt");
|
||||||
|
if (scheduleEndAt != null && nowMs >= scheduleEndAt) {
|
||||||
|
return { playable: false, reason: ELIGIBILITY_REASONS.SCHEDULED_ENDED };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read(asset, "status", "status") !== "ready") {
|
||||||
|
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_NOT_READY };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationAt = read(asset, "expiration_at", "expirationAt");
|
||||||
|
if (expirationAt != null && nowMs >= expirationAt) {
|
||||||
|
return { playable: false, reason: ELIGIBILITY_REASONS.ASSET_EXPIRED };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { playable: true, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntrySettings(entry, appSettings) {
|
||||||
|
const durationOverrideMs = read(
|
||||||
|
entry,
|
||||||
|
"duration_override_ms",
|
||||||
|
"durationOverrideMs",
|
||||||
|
);
|
||||||
|
const backgroundModeOverride = read(
|
||||||
|
entry,
|
||||||
|
"background_mode_override",
|
||||||
|
"backgroundModeOverride",
|
||||||
|
);
|
||||||
|
const backgroundColorOverride = read(
|
||||||
|
entry,
|
||||||
|
"background_color_override",
|
||||||
|
"backgroundColorOverride",
|
||||||
|
);
|
||||||
|
const backgroundDarkenedOverride = read(
|
||||||
|
entry,
|
||||||
|
"background_darkened_override",
|
||||||
|
"backgroundDarkenedOverride",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
durationMs:
|
||||||
|
durationOverrideMs ??
|
||||||
|
read(appSettings, "default_duration_ms", "defaultDurationMs"),
|
||||||
|
presentation: {
|
||||||
|
backgroundMode:
|
||||||
|
backgroundModeOverride ??
|
||||||
|
read(appSettings, "default_background_mode", "defaultBackgroundMode"),
|
||||||
|
backgroundColor:
|
||||||
|
backgroundColorOverride ??
|
||||||
|
read(
|
||||||
|
appSettings,
|
||||||
|
"default_solid_background_color",
|
||||||
|
"defaultSolidBackgroundColor",
|
||||||
|
),
|
||||||
|
backgroundDarkened: Boolean(
|
||||||
|
backgroundDarkenedOverride ??
|
||||||
|
read(
|
||||||
|
appSettings,
|
||||||
|
"default_background_darkened",
|
||||||
|
"defaultBackgroundDarkened",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
blurPx: read(appSettings, "default_blur_px", "defaultBlurPx"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ELIGIBILITY_REASONS,
|
||||||
|
evaluateEntryEligibility,
|
||||||
|
resolveEntrySettings,
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = require("./eligibility");
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
class PlaybackError extends Error {
|
||||||
|
constructor(code, message, { statusCode = 400, details } = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PlaybackError";
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
if (details !== undefined) this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playlistNotFound(playlistId) {
|
||||||
|
return new PlaybackError("PLAYLIST_NOT_FOUND", "Playlist not found", {
|
||||||
|
statusCode: 404,
|
||||||
|
details: { playlistId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetNotFound(assetId) {
|
||||||
|
return new PlaybackError("ASSET_NOT_FOUND", "Library asset not found", {
|
||||||
|
statusCode: 404,
|
||||||
|
details: { assetId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PlaybackError,
|
||||||
|
assetNotFound,
|
||||||
|
playlistNotFound,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const { PlaybackError } = require("./errors");
|
||||||
|
const { createPlaybackService } = require("./service");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PlaybackError,
|
||||||
|
createPlaybackService,
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const express = require("express");
|
||||||
|
|
||||||
|
function createPlaybackRouter(playbackService) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playbackService.getSnapshot());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/pause", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playbackService.pause());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/resume", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playbackService.resume());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/next", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playbackService.next());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/previous", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playbackService.previous());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/static", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playbackService.enterStatic(request.body?.assetId));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/mode", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playbackService.setMode(request.body?.mode));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/reload", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playbackService.reload());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPlaybackRouter,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
class PlaylistError extends Error {
|
||||||
|
constructor(code, message, { statusCode = 400, details, cause } = {}) {
|
||||||
|
super(message, cause ? { cause } : undefined);
|
||||||
|
this.name = "PlaylistError";
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
if (details !== undefined) {
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playlistNotFound(playlistId) {
|
||||||
|
return new PlaylistError("PLAYLIST_NOT_FOUND", "Playlist not found", {
|
||||||
|
statusCode: 404,
|
||||||
|
details: { playlistId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playlistEntryNotFound(playlistId, entryId) {
|
||||||
|
return new PlaylistError("PLAYLIST_ENTRY_NOT_FOUND", "Playlist entry not found", {
|
||||||
|
statusCode: 404,
|
||||||
|
details: { playlistId, entryId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PlaylistError,
|
||||||
|
playlistEntryNotFound,
|
||||||
|
playlistNotFound,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const { createPlaylistRouter } = require("./routes");
|
||||||
|
const { createPlaylistService } = require("./service");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPlaylistRouter,
|
||||||
|
createPlaylistService,
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { PlaylistError } = require("./errors");
|
||||||
|
|
||||||
|
function orderedEntryIds(body) {
|
||||||
|
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ORDER",
|
||||||
|
"Playlist order must be an object containing entryIds",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const fields = Object.keys(body);
|
||||||
|
if (fields.length !== 1 || fields[0] !== "entryIds") {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ORDER",
|
||||||
|
"Playlist order must contain only entryIds",
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
fields,
|
||||||
|
requiredFields: ["entryIds"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body.entryIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlaylistRouter(
|
||||||
|
playlistService,
|
||||||
|
{ playbackService } = {},
|
||||||
|
) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json({ playlists: playlistService.list() });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.status(201).json(playlistService.create(request.body));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:playlistId", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playlistService.get(request.params.playlistId));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/:playlistId", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(
|
||||||
|
playlistService.update(request.params.playlistId, request.body),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:playlistId", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(playlistService.remove(request.params.playlistId));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/:playlistId/entries", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response
|
||||||
|
.status(201)
|
||||||
|
.json(playlistService.addEntry(request.params.playlistId, request.body));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/:playlistId/entries/:entryId", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(
|
||||||
|
playlistService.updateEntry(
|
||||||
|
request.params.playlistId,
|
||||||
|
request.params.entryId,
|
||||||
|
request.body,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:playlistId/entries/:entryId", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(
|
||||||
|
playlistService.removeEntry(
|
||||||
|
request.params.playlistId,
|
||||||
|
request.params.entryId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/:playlistId/entries/order", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
response.json(
|
||||||
|
playlistService.reorderEntries(
|
||||||
|
request.params.playlistId,
|
||||||
|
orderedEntryIds(request.body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/:playlistId/activate", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!playbackService) {
|
||||||
|
throw new Error("The playback service is required for playlist activation");
|
||||||
|
}
|
||||||
|
const result = playbackService.activatePlaylist(request.params.playlistId);
|
||||||
|
response.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPlaylistRouter,
|
||||||
|
orderedEntryIds,
|
||||||
|
};
|
||||||
@@ -0,0 +1,884 @@
|
|||||||
|
const crypto = require("node:crypto");
|
||||||
|
const {
|
||||||
|
evaluateEntryEligibility,
|
||||||
|
resolveEntrySettings,
|
||||||
|
} = require("../lifecycle");
|
||||||
|
const {
|
||||||
|
PlaylistError,
|
||||||
|
playlistEntryNotFound,
|
||||||
|
playlistNotFound,
|
||||||
|
} = require("./errors");
|
||||||
|
|
||||||
|
const MIN_DURATION_MS = 1_000;
|
||||||
|
const MAX_DURATION_MS = 3_600_000;
|
||||||
|
const MAX_PLAYLIST_NAME_LENGTH = 200;
|
||||||
|
const BACKGROUND_MODES = new Set(["blurred", "solid", "black"]);
|
||||||
|
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||||
|
const ENTRY_PATCH_FIELDS = new Set([
|
||||||
|
"enabled",
|
||||||
|
"durationOverrideMs",
|
||||||
|
"backgroundModeOverride",
|
||||||
|
"backgroundColorOverride",
|
||||||
|
"backgroundDarkenedOverride",
|
||||||
|
"scheduleStartAt",
|
||||||
|
"scheduleEndAt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeObject(input, code, message) {
|
||||||
|
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
throw new PlaylistError(code, message);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlaylistName(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_NAME",
|
||||||
|
"Playlist name must be a string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const normalized = value
|
||||||
|
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_NAME",
|
||||||
|
"Playlist name cannot be empty",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ([...normalized].length > MAX_PLAYLIST_NAME_LENGTH) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_NAME",
|
||||||
|
`Playlist name cannot exceed ${MAX_PLAYLIST_NAME_LENGTH} characters`,
|
||||||
|
{ details: { maxLength: MAX_PLAYLIST_NAME_LENGTH } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBoolean(value, field) {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
`${field} must be a boolean`,
|
||||||
|
{ details: { field } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableDuration(value) {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(value) ||
|
||||||
|
value < MIN_DURATION_MS ||
|
||||||
|
value > MAX_DURATION_MS
|
||||||
|
) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
`durationOverrideMs must be null or an integer from ${MIN_DURATION_MS} through ${MAX_DURATION_MS}`,
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
field: "durationOverrideMs",
|
||||||
|
min: MIN_DURATION_MS,
|
||||||
|
max: MAX_DURATION_MS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableBackgroundMode(value) {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (typeof value !== "string" || !BACKGROUND_MODES.has(value)) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
"backgroundModeOverride must be null, blurred, solid, or black",
|
||||||
|
{ details: { field: "backgroundModeOverride" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableColor(value) {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (typeof value !== "string" || !HEX_COLOR_PATTERN.test(value)) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
"backgroundColorOverride must be null or a six-digit hex color",
|
||||||
|
{ details: { field: "backgroundColorOverride" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableDarkening(value) {
|
||||||
|
if (value === null) return null;
|
||||||
|
return normalizeBoolean(value, "backgroundDarkenedOverride");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableInstant(value, field) {
|
||||||
|
if (value === null) return null;
|
||||||
|
if (Number.isSafeInteger(value) && value >= 0) return value;
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const instant = value.trim();
|
||||||
|
if (!/([zZ]|[+-]\d{2}:\d{2})$/.test(instant)) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
`${field} ISO dates must include Z or a numeric UTC offset`,
|
||||||
|
{ details: { field } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const timestamp = Date.parse(instant);
|
||||||
|
if (Number.isSafeInteger(timestamp) && timestamp >= 0) return timestamp;
|
||||||
|
}
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
`${field} must be null, an epoch-millisecond integer, or an ISO date`,
|
||||||
|
{ details: { field } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSchedule(startAt, endAt) {
|
||||||
|
if (startAt !== null && endAt !== null && startAt >= endAt) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY_SCHEDULE",
|
||||||
|
"scheduleStartAt must be earlier than scheduleEndAt",
|
||||||
|
{ details: { scheduleStartAt: startAt, scheduleEndAt: endAt } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPlaylist(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
revision: row.revision,
|
||||||
|
entryCount: Number(row.entry_count || 0),
|
||||||
|
isActive: Boolean(row.is_active),
|
||||||
|
isPendingActivation: Boolean(row.is_pending),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaUrl(assetId, kind, sha256) {
|
||||||
|
return sha256
|
||||||
|
? `/media/assets/${assetId}/${kind}/${sha256}.webp`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapResolvedEntry(row, settings, nowMs) {
|
||||||
|
const eligibility = evaluateEntryEligibility(row, row, nowMs);
|
||||||
|
const resolved = resolveEntrySettings(row, settings);
|
||||||
|
return {
|
||||||
|
id: row.entry_id,
|
||||||
|
playlistId: row.playlist_id,
|
||||||
|
assetId: row.asset_id,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
position: row.position,
|
||||||
|
durationOverrideMs: row.duration_override_ms,
|
||||||
|
effectiveDurationMs: resolved.durationMs,
|
||||||
|
backgroundModeOverride: row.background_mode_override,
|
||||||
|
backgroundColorOverride: row.background_color_override,
|
||||||
|
backgroundDarkenedOverride:
|
||||||
|
row.background_darkened_override === null
|
||||||
|
? null
|
||||||
|
: Boolean(row.background_darkened_override),
|
||||||
|
effectivePresentation: resolved.presentation,
|
||||||
|
scheduleStartAt: row.schedule_start_at,
|
||||||
|
scheduleEndAt: row.schedule_end_at,
|
||||||
|
playable: eligibility.playable,
|
||||||
|
notPlayableReason: eligibility.reason,
|
||||||
|
eligibility,
|
||||||
|
asset: {
|
||||||
|
id: row.asset_id,
|
||||||
|
displayName: row.asset_display_name,
|
||||||
|
originalFilename: row.asset_original_filename,
|
||||||
|
status: row.status,
|
||||||
|
expirationAt: row.expiration_at,
|
||||||
|
display: {
|
||||||
|
width: row.display_width,
|
||||||
|
height: row.display_height,
|
||||||
|
url: mediaUrl(row.asset_id, "display", row.display_sha256),
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
width: row.thumbnail_width,
|
||||||
|
height: row.thumbnail_height,
|
||||||
|
url: mediaUrl(row.asset_id, "thumbnail", row.thumbnail_sha256),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: row.entry_created_at,
|
||||||
|
updatedAt: row.entry_updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlaylistService({
|
||||||
|
database,
|
||||||
|
now = Date.now,
|
||||||
|
createId = crypto.randomUUID,
|
||||||
|
onBeforeChange = () => {},
|
||||||
|
onChanged = () => {},
|
||||||
|
}) {
|
||||||
|
const playlistSelect = `
|
||||||
|
SELECT p.*,
|
||||||
|
(SELECT COUNT(*) FROM playlist_entries pe WHERE pe.playlist_id = p.id)
|
||||||
|
AS entry_count,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM playback_state ps WHERE ps.active_playlist_id = p.id
|
||||||
|
) AS is_active,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM playback_state ps WHERE ps.pending_playlist_id = p.id
|
||||||
|
) AS is_pending
|
||||||
|
FROM playlists p
|
||||||
|
`;
|
||||||
|
const listPlaylists = database.prepare(
|
||||||
|
`${playlistSelect} ORDER BY p.name COLLATE NOCASE, p.id`,
|
||||||
|
);
|
||||||
|
const findPlaylist = database.prepare(`${playlistSelect} WHERE p.id = ?`);
|
||||||
|
const findPlaylistByName = database.prepare(
|
||||||
|
`${playlistSelect} WHERE p.name = ? COLLATE NOCASE`,
|
||||||
|
);
|
||||||
|
const insertPlaylist = database.prepare(`
|
||||||
|
INSERT INTO playlists (id, name, revision, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?)
|
||||||
|
`);
|
||||||
|
const renamePlaylist = database.prepare(`
|
||||||
|
UPDATE playlists
|
||||||
|
SET name = ?, revision = revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
const deletePlaylist = database.prepare("DELETE FROM playlists WHERE id = ?");
|
||||||
|
const bumpPlaylistRevision = database.prepare(`
|
||||||
|
UPDATE playlists
|
||||||
|
SET revision = revision + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
const readSettings = database.prepare("SELECT * FROM app_settings WHERE id = 1");
|
||||||
|
const readPlayback = database.prepare(
|
||||||
|
"SELECT * FROM playback_state WHERE output_id = 'global'",
|
||||||
|
);
|
||||||
|
const resolvedEntries = database.prepare(`
|
||||||
|
SELECT
|
||||||
|
pe.id AS entry_id,
|
||||||
|
pe.playlist_id,
|
||||||
|
pe.asset_id,
|
||||||
|
pe.enabled,
|
||||||
|
pe.duration_override_ms,
|
||||||
|
pe.background_mode_override,
|
||||||
|
pe.background_color_override,
|
||||||
|
pe.background_darkened_override,
|
||||||
|
pe.schedule_start_at,
|
||||||
|
pe.schedule_end_at,
|
||||||
|
pe.position,
|
||||||
|
pe.created_at AS entry_created_at,
|
||||||
|
pe.updated_at AS entry_updated_at,
|
||||||
|
a.display_name AS asset_display_name,
|
||||||
|
a.original_filename AS asset_original_filename,
|
||||||
|
a.status,
|
||||||
|
a.expiration_at,
|
||||||
|
a.display_sha256,
|
||||||
|
a.display_width,
|
||||||
|
a.display_height,
|
||||||
|
a.thumbnail_sha256,
|
||||||
|
a.thumbnail_width,
|
||||||
|
a.thumbnail_height
|
||||||
|
FROM playlist_entries pe
|
||||||
|
JOIN assets a ON a.id = pe.asset_id
|
||||||
|
WHERE pe.playlist_id = ?
|
||||||
|
ORDER BY pe.position, pe.id
|
||||||
|
`);
|
||||||
|
const findEntry = database.prepare(`
|
||||||
|
SELECT pe.*
|
||||||
|
FROM playlist_entries pe
|
||||||
|
WHERE pe.playlist_id = ? AND pe.id = ?
|
||||||
|
`);
|
||||||
|
const findEntryByAsset = database.prepare(`
|
||||||
|
SELECT pe.*
|
||||||
|
FROM playlist_entries pe
|
||||||
|
WHERE pe.playlist_id = ? AND pe.asset_id = ?
|
||||||
|
`);
|
||||||
|
const findEntryOwner = database.prepare(
|
||||||
|
"SELECT playlist_id FROM playlist_entries WHERE id = ?",
|
||||||
|
);
|
||||||
|
const listEntryIds = database.prepare(`
|
||||||
|
SELECT id, position
|
||||||
|
FROM playlist_entries
|
||||||
|
WHERE playlist_id = ?
|
||||||
|
ORDER BY position, id
|
||||||
|
`);
|
||||||
|
const findAsset = database.prepare("SELECT * FROM assets WHERE id = ?");
|
||||||
|
const nextPosition = database.prepare(`
|
||||||
|
SELECT COALESCE(MAX(position), -1) + 1 AS position
|
||||||
|
FROM playlist_entries
|
||||||
|
WHERE playlist_id = ?
|
||||||
|
`);
|
||||||
|
const insertEntry = database.prepare(`
|
||||||
|
INSERT INTO playlist_entries (
|
||||||
|
id, playlist_id, asset_id, enabled, duration_override_ms,
|
||||||
|
background_mode_override, background_color_override,
|
||||||
|
background_darkened_override, schedule_start_at, schedule_end_at,
|
||||||
|
position, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @playlistId, @assetId, @enabled, @durationOverrideMs,
|
||||||
|
@backgroundModeOverride, @backgroundColorOverride,
|
||||||
|
@backgroundDarkenedOverride, @scheduleStartAt, @scheduleEndAt,
|
||||||
|
@position, @createdAt, @updatedAt
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const updateEntry = database.prepare(`
|
||||||
|
UPDATE playlist_entries
|
||||||
|
SET enabled = @enabled,
|
||||||
|
duration_override_ms = @durationOverrideMs,
|
||||||
|
background_mode_override = @backgroundModeOverride,
|
||||||
|
background_color_override = @backgroundColorOverride,
|
||||||
|
background_darkened_override = @backgroundDarkenedOverride,
|
||||||
|
schedule_start_at = @scheduleStartAt,
|
||||||
|
schedule_end_at = @scheduleEndAt,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
WHERE playlist_id = @playlistId AND id = @entryId
|
||||||
|
`);
|
||||||
|
const deleteEntry = database.prepare(
|
||||||
|
"DELETE FROM playlist_entries WHERE playlist_id = ? AND id = ?",
|
||||||
|
);
|
||||||
|
const moveEntriesOutOfRange = database.prepare(`
|
||||||
|
UPDATE playlist_entries
|
||||||
|
SET position = position + ?
|
||||||
|
WHERE playlist_id = ?
|
||||||
|
`);
|
||||||
|
const setEntryPosition = database.prepare(`
|
||||||
|
UPDATE playlist_entries
|
||||||
|
SET position = ?, updated_at = ?
|
||||||
|
WHERE playlist_id = ? AND id = ?
|
||||||
|
`);
|
||||||
|
function getPlaylistRow(playlistId) {
|
||||||
|
const row = findPlaylist.get(playlistId);
|
||||||
|
if (!row) throw playlistNotFound(playlistId);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntryRow(playlistId, entryId) {
|
||||||
|
getPlaylistRow(playlistId);
|
||||||
|
const row = findEntry.get(playlistId, entryId);
|
||||||
|
if (!row) throw playlistEntryNotFound(playlistId, entryId);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTimestamp(row) {
|
||||||
|
return Math.max(now(), row.updated_at + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(change) {
|
||||||
|
// The transaction has committed before this synchronous domain hook runs.
|
||||||
|
// A reconciliation failure is surfaced to the caller so playback cannot
|
||||||
|
// silently remain inconsistent with a successful playlist mutation.
|
||||||
|
onChanged(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateName(name, row = findPlaylistByName.get(name)) {
|
||||||
|
return new PlaylistError(
|
||||||
|
"PLAYLIST_NAME_CONFLICT",
|
||||||
|
"Another playlist already uses that name",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
details: row ? { playlist: mapPlaylist(row) } : { name },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCreate(input) {
|
||||||
|
normalizeObject(input, "INVALID_PLAYLIST", "Playlist must be an object");
|
||||||
|
const unknown = Object.keys(input).filter((key) => key !== "name");
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST",
|
||||||
|
"Playlist contains unsupported fields",
|
||||||
|
{ details: { fields: unknown } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Object.hasOwn(input, "name")) {
|
||||||
|
throw new PlaylistError("INVALID_PLAYLIST", "Playlist name is required", {
|
||||||
|
details: { field: "name" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return normalizePlaylistName(input.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEntryFields(input, { creating = false, current = null } = {}) {
|
||||||
|
normalizeObject(
|
||||||
|
input,
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
"Playlist entry must be an object",
|
||||||
|
);
|
||||||
|
const allowed = creating
|
||||||
|
? new Set(["assetId", ...ENTRY_PATCH_FIELDS])
|
||||||
|
: ENTRY_PATCH_FIELDS;
|
||||||
|
const unknown = Object.keys(input).filter((key) => !allowed.has(key));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
"Playlist entry contains unsupported fields",
|
||||||
|
{ details: { fields: unknown } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!creating && Object.keys(input).length === 0) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
"At least one playlist entry field is required",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (creating && (typeof input.assetId !== "string" || !input.assetId.trim())) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ENTRY",
|
||||||
|
"assetId is required",
|
||||||
|
{ details: { field: "assetId" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = (field, fallback) =>
|
||||||
|
Object.hasOwn(input, field) ? input[field] : fallback;
|
||||||
|
const normalized = {
|
||||||
|
assetId: creating ? input.assetId.trim() : current.asset_id,
|
||||||
|
enabled: Object.hasOwn(input, "enabled")
|
||||||
|
? normalizeBoolean(input.enabled, "enabled")
|
||||||
|
: (current?.enabled ?? 1),
|
||||||
|
durationOverrideMs: Object.hasOwn(input, "durationOverrideMs")
|
||||||
|
? normalizeNullableDuration(input.durationOverrideMs)
|
||||||
|
: (current?.duration_override_ms ?? null),
|
||||||
|
backgroundModeOverride: Object.hasOwn(input, "backgroundModeOverride")
|
||||||
|
? normalizeNullableBackgroundMode(input.backgroundModeOverride)
|
||||||
|
: (current?.background_mode_override ?? null),
|
||||||
|
backgroundColorOverride: Object.hasOwn(input, "backgroundColorOverride")
|
||||||
|
? normalizeNullableColor(input.backgroundColorOverride)
|
||||||
|
: (current?.background_color_override ?? null),
|
||||||
|
backgroundDarkenedOverride: Object.hasOwn(
|
||||||
|
input,
|
||||||
|
"backgroundDarkenedOverride",
|
||||||
|
)
|
||||||
|
? normalizeNullableDarkening(input.backgroundDarkenedOverride)
|
||||||
|
: (current?.background_darkened_override ?? null),
|
||||||
|
scheduleStartAt: normalizeNullableInstant(
|
||||||
|
value("scheduleStartAt", current?.schedule_start_at ?? null),
|
||||||
|
"scheduleStartAt",
|
||||||
|
),
|
||||||
|
scheduleEndAt: normalizeNullableInstant(
|
||||||
|
value("scheduleEndAt", current?.schedule_end_at ?? null),
|
||||||
|
"scheduleEndAt",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
validateSchedule(normalized.scheduleStartAt, normalized.scheduleEndAt);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryValuesEqual(current, next) {
|
||||||
|
return (
|
||||||
|
current.enabled === next.enabled &&
|
||||||
|
current.duration_override_ms === next.durationOverrideMs &&
|
||||||
|
current.background_mode_override === next.backgroundModeOverride &&
|
||||||
|
current.background_color_override === next.backgroundColorOverride &&
|
||||||
|
current.background_darkened_override === next.backgroundDarkenedOverride &&
|
||||||
|
current.schedule_start_at === next.scheduleStartAt &&
|
||||||
|
current.schedule_end_at === next.scheduleEndAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedDetail(playlistId, nowMs = now()) {
|
||||||
|
const playlist = mapPlaylist(getPlaylistRow(playlistId));
|
||||||
|
const settings = readSettings.get();
|
||||||
|
if (!settings) throw new Error("The app_settings singleton row is missing");
|
||||||
|
return {
|
||||||
|
...playlist,
|
||||||
|
evaluatedAt: nowMs,
|
||||||
|
settingsRevision: settings.revision,
|
||||||
|
entries: resolvedEntries
|
||||||
|
.all(playlistId)
|
||||||
|
.map((row) => mapResolvedEntry(row, settings, nowMs)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTransaction = database.transaction((input) => {
|
||||||
|
const name = validateCreate(input);
|
||||||
|
const existing = findPlaylistByName.get(name);
|
||||||
|
if (existing) throw duplicateName(name, existing);
|
||||||
|
const timestamp = now();
|
||||||
|
const id = createId();
|
||||||
|
try {
|
||||||
|
insertPlaylist.run(id, name, timestamp, timestamp);
|
||||||
|
} catch (error) {
|
||||||
|
const conflict = findPlaylistByName.get(name);
|
||||||
|
if (conflict) throw duplicateName(name, conflict);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return mapPlaylist(getPlaylistRow(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
function create(input) {
|
||||||
|
const playlist = createTransaction(input);
|
||||||
|
notify({ reason: "created", playlistId: playlist.id, revision: playlist.revision });
|
||||||
|
return playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
return listPlaylists.all().map(mapPlaylist);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(playlistId) {
|
||||||
|
return resolvedDetail(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTransaction = database.transaction((playlistId, input) => {
|
||||||
|
normalizeObject(input, "INVALID_PLAYLIST", "Playlist update must be an object");
|
||||||
|
const unknown = Object.keys(input).filter((key) => key !== "name");
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST",
|
||||||
|
"Playlist update contains unsupported fields",
|
||||||
|
{ details: { fields: unknown } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Object.hasOwn(input, "name") || Object.keys(input).length !== 1) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST",
|
||||||
|
"Playlist update requires a name",
|
||||||
|
{ details: { field: "name" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const current = getPlaylistRow(playlistId);
|
||||||
|
const name = normalizePlaylistName(input.name);
|
||||||
|
if (current.name === name) {
|
||||||
|
return { changed: false, playlist: mapPlaylist(current) };
|
||||||
|
}
|
||||||
|
const conflict = findPlaylistByName.get(name);
|
||||||
|
if (conflict && conflict.id !== playlistId) throw duplicateName(name, conflict);
|
||||||
|
try {
|
||||||
|
renamePlaylist.run(name, nextTimestamp(current), playlistId);
|
||||||
|
} catch (error) {
|
||||||
|
const raced = findPlaylistByName.get(name);
|
||||||
|
if (raced && raced.id !== playlistId) throw duplicateName(name, raced);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { changed: true, playlist: mapPlaylist(getPlaylistRow(playlistId)) };
|
||||||
|
});
|
||||||
|
|
||||||
|
function update(playlistId, input) {
|
||||||
|
onBeforeChange({ reason: "before_playlist_update", playlistId });
|
||||||
|
const result = updateTransaction(playlistId, input);
|
||||||
|
if (result.changed) {
|
||||||
|
notify({
|
||||||
|
reason: "renamed",
|
||||||
|
playlistId,
|
||||||
|
revision: result.playlist.revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTransaction = database.transaction((playlistId) => {
|
||||||
|
const playlist = getPlaylistRow(playlistId);
|
||||||
|
const state = readPlayback.get();
|
||||||
|
if (!state) throw new Error("The global playback_state row is missing");
|
||||||
|
if (state.active_playlist_id === playlistId) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"PLAYLIST_ACTIVE",
|
||||||
|
"The active playlist cannot be deleted",
|
||||||
|
{ statusCode: 409, details: { playlistId } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.pending_playlist_id === playlistId) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"PLAYLIST_PENDING_ACTIVATION",
|
||||||
|
"A playlist pending activation cannot be deleted",
|
||||||
|
{ statusCode: 409, details: { playlistId } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
deletePlaylist.run(playlistId);
|
||||||
|
return playlist;
|
||||||
|
});
|
||||||
|
|
||||||
|
function remove(playlistId) {
|
||||||
|
onBeforeChange({ reason: "before_playlist_delete", playlistId });
|
||||||
|
const playlist = removeTransaction(playlistId);
|
||||||
|
notify({ reason: "deleted", playlistId, revision: playlist.revision });
|
||||||
|
return { id: playlistId, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEntryTransaction = database.transaction((playlistId, input) => {
|
||||||
|
const playlist = getPlaylistRow(playlistId);
|
||||||
|
const fields = validateEntryFields(input, { creating: true });
|
||||||
|
const operationNow = now();
|
||||||
|
const asset = findAsset.get(fields.assetId);
|
||||||
|
if (!asset) {
|
||||||
|
throw new PlaylistError("ASSET_NOT_FOUND", "Library asset not found", {
|
||||||
|
statusCode: 404,
|
||||||
|
details: { assetId: fields.assetId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (asset.status !== "ready") {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"ASSET_NOT_READY",
|
||||||
|
"Only ready library assets can be added to playlists",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
details: { assetId: asset.id, status: asset.status },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (asset.expiration_at !== null && asset.expiration_at <= operationNow) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"ASSET_EXPIRED",
|
||||||
|
"Expired library assets cannot be added to playlists",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
details: { assetId: asset.id, expirationAt: asset.expiration_at },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = findEntryByAsset.get(playlistId, fields.assetId);
|
||||||
|
if (existing) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"PLAYLIST_ENTRY_EXISTS",
|
||||||
|
"This asset already appears in the playlist",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
details: { playlistId, assetId: fields.assetId, entryId: existing.id },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const timestamp = Math.max(operationNow, playlist.updated_at + 1);
|
||||||
|
const id = createId();
|
||||||
|
try {
|
||||||
|
insertEntry.run({
|
||||||
|
id,
|
||||||
|
playlistId,
|
||||||
|
...fields,
|
||||||
|
position: nextPosition.get(playlistId).position,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const raced = findEntryByAsset.get(playlistId, fields.assetId);
|
||||||
|
if (raced) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"PLAYLIST_ENTRY_EXISTS",
|
||||||
|
"This asset already appears in the playlist",
|
||||||
|
{
|
||||||
|
statusCode: 409,
|
||||||
|
details: { playlistId, assetId: fields.assetId, entryId: raced.id },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
bumpPlaylistRevision.run(timestamp, playlistId);
|
||||||
|
return {
|
||||||
|
playlist: getPlaylistRow(playlistId),
|
||||||
|
entryId: id,
|
||||||
|
detail: resolvedDetail(playlistId, now()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function addEntry(playlistId, input) {
|
||||||
|
onBeforeChange({ reason: "before_entry_add", playlistId });
|
||||||
|
const result = addEntryTransaction(playlistId, input);
|
||||||
|
notify({
|
||||||
|
reason: "entry_added",
|
||||||
|
playlistId,
|
||||||
|
revision: result.playlist.revision,
|
||||||
|
entryId: result.entryId,
|
||||||
|
});
|
||||||
|
return result.detail.entries.find((entry) => entry.id === result.entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchEntryTransaction = database.transaction((playlistId, entryId, input) => {
|
||||||
|
const playlist = getPlaylistRow(playlistId);
|
||||||
|
const current = getEntryRow(playlistId, entryId);
|
||||||
|
const fields = validateEntryFields(input, { current });
|
||||||
|
if (entryValuesEqual(current, fields)) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
invalidBoundaryChanged: false,
|
||||||
|
playlist,
|
||||||
|
detail: resolvedDetail(playlistId, now()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const timestamp = nextTimestamp(playlist);
|
||||||
|
updateEntry.run({
|
||||||
|
playlistId,
|
||||||
|
entryId,
|
||||||
|
...fields,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
});
|
||||||
|
bumpPlaylistRevision.run(timestamp, playlistId);
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
invalidBoundaryChanged:
|
||||||
|
current.schedule_end_at !== fields.scheduleEndAt,
|
||||||
|
playlist: getPlaylistRow(playlistId),
|
||||||
|
detail: resolvedDetail(playlistId, now()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateEntryById(playlistId, entryId, input) {
|
||||||
|
onBeforeChange({
|
||||||
|
reason: "before_entry_update",
|
||||||
|
playlistId,
|
||||||
|
entryId,
|
||||||
|
});
|
||||||
|
const result = patchEntryTransaction(playlistId, entryId, input);
|
||||||
|
if (result.changed) {
|
||||||
|
notify({
|
||||||
|
reason: "entry_updated",
|
||||||
|
playlistId,
|
||||||
|
revision: result.playlist.revision,
|
||||||
|
entryId,
|
||||||
|
...(result.invalidBoundaryChanged
|
||||||
|
? { invalidBoundaryChanged: true }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.detail.entries.find((entry) => entry.id === entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeEntryTransaction = database.transaction((playlistId, entryId) => {
|
||||||
|
const playlist = getPlaylistRow(playlistId);
|
||||||
|
getEntryRow(playlistId, entryId);
|
||||||
|
const timestamp = nextTimestamp(playlist);
|
||||||
|
const result = deleteEntry.run(playlistId, entryId);
|
||||||
|
if (result.changes !== 1) throw playlistEntryNotFound(playlistId, entryId);
|
||||||
|
const remaining = listEntryIds.all(playlistId);
|
||||||
|
if (remaining.some((entry, index) => entry.position !== index)) {
|
||||||
|
moveEntriesOutOfRange.run(remaining.length + 1, playlistId);
|
||||||
|
remaining.forEach((entry, position) => {
|
||||||
|
setEntryPosition.run(position, timestamp, playlistId, entry.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bumpPlaylistRevision.run(timestamp, playlistId);
|
||||||
|
return { playlist: getPlaylistRow(playlistId) };
|
||||||
|
});
|
||||||
|
|
||||||
|
function removeEntryById(playlistId, entryId) {
|
||||||
|
onBeforeChange({
|
||||||
|
reason: "before_entry_delete",
|
||||||
|
playlistId,
|
||||||
|
entryId,
|
||||||
|
});
|
||||||
|
const result = removeEntryTransaction(playlistId, entryId);
|
||||||
|
notify({
|
||||||
|
reason: "entry_deleted",
|
||||||
|
playlistId,
|
||||||
|
revision: result.playlist.revision,
|
||||||
|
entryId,
|
||||||
|
});
|
||||||
|
return { id: entryId, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOrder(playlistId, orderedEntryIds) {
|
||||||
|
if (!Array.isArray(orderedEntryIds)) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ORDER",
|
||||||
|
"Playlist order must be a complete array of entry IDs",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (orderedEntryIds.some((id) => typeof id !== "string" || !id)) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"INVALID_PLAYLIST_ORDER",
|
||||||
|
"Every ordered entry ID must be a non-empty string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const duplicates = orderedEntryIds.filter(
|
||||||
|
(id, index) => orderedEntryIds.indexOf(id) !== index,
|
||||||
|
);
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"PLAYLIST_ORDER_DUPLICATE_IDS",
|
||||||
|
"Playlist order contains duplicate entry IDs",
|
||||||
|
{ details: { entryIds: [...new Set(duplicates)] } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = listEntryIds.all(playlistId);
|
||||||
|
const currentIds = new Set(current.map((entry) => entry.id));
|
||||||
|
const suppliedIds = new Set(orderedEntryIds);
|
||||||
|
const extra = orderedEntryIds.filter((id) => !currentIds.has(id));
|
||||||
|
if (extra.length > 0) {
|
||||||
|
const foreign = extra.filter((id) => {
|
||||||
|
const owner = findEntryOwner.get(id);
|
||||||
|
return owner && owner.playlist_id !== playlistId;
|
||||||
|
});
|
||||||
|
if (foreign.length > 0) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"PLAYLIST_ORDER_FOREIGN_ENTRY",
|
||||||
|
"Playlist order contains an entry from another playlist",
|
||||||
|
{ details: { entryIds: foreign } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new PlaylistError(
|
||||||
|
"PLAYLIST_ORDER_UNKNOWN_ENTRY",
|
||||||
|
"Playlist order contains an unknown entry ID",
|
||||||
|
{ details: { entryIds: extra } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const missing = current
|
||||||
|
.map((entry) => entry.id)
|
||||||
|
.filter((id) => !suppliedIds.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new PlaylistError(
|
||||||
|
"PLAYLIST_ORDER_INCOMPLETE",
|
||||||
|
"Playlist order is missing current entries",
|
||||||
|
{ details: { entryIds: missing } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderTransaction = database.transaction((playlistId, orderedEntryIds) => {
|
||||||
|
const playlist = getPlaylistRow(playlistId);
|
||||||
|
const current = validateOrder(playlistId, orderedEntryIds);
|
||||||
|
if (current.every((entry, index) => entry.id === orderedEntryIds[index])) {
|
||||||
|
return { changed: false, playlist, detail: resolvedDetail(playlistId, now()) };
|
||||||
|
}
|
||||||
|
const timestamp = nextTimestamp(playlist);
|
||||||
|
const offset = Math.max(current.length, 1);
|
||||||
|
moveEntriesOutOfRange.run(offset, playlistId);
|
||||||
|
orderedEntryIds.forEach((entryId, position) => {
|
||||||
|
setEntryPosition.run(position, timestamp, playlistId, entryId);
|
||||||
|
});
|
||||||
|
bumpPlaylistRevision.run(timestamp, playlistId);
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
playlist: getPlaylistRow(playlistId),
|
||||||
|
detail: resolvedDetail(playlistId, now()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function reorderEntries(playlistId, orderedEntryIds) {
|
||||||
|
onBeforeChange({ reason: "before_entries_reorder", playlistId });
|
||||||
|
const result = reorderTransaction(playlistId, orderedEntryIds);
|
||||||
|
if (result.changed) {
|
||||||
|
notify({ reason: "entries_reordered", playlistId, revision: result.playlist.revision });
|
||||||
|
}
|
||||||
|
return result.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addEntry,
|
||||||
|
create,
|
||||||
|
get,
|
||||||
|
list,
|
||||||
|
remove,
|
||||||
|
removeEntry: removeEntryById,
|
||||||
|
reorderEntries,
|
||||||
|
update,
|
||||||
|
updateEntry: updateEntryById,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MAX_DURATION_MS,
|
||||||
|
MIN_DURATION_MS,
|
||||||
|
createPlaylistService,
|
||||||
|
normalizePlaylistName,
|
||||||
|
};
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
const http = require("node:http");
|
||||||
|
const { Server: SocketServer } = require("socket.io");
|
||||||
|
const { createApp } = require("./app");
|
||||||
|
const { openDatabase } = require("./database");
|
||||||
|
const { runMigrations } = require("./database/migrate");
|
||||||
|
const { createDisplayService } = require("./displays/service");
|
||||||
|
const { createImageProcessor } = require("./image-processing");
|
||||||
|
const { createMqttService } = require("./mqtt");
|
||||||
|
const { createNotificationService } = require("./notifications");
|
||||||
|
const {
|
||||||
|
observeAssetProcessingFailure,
|
||||||
|
observePlayback,
|
||||||
|
observeStorageHealth,
|
||||||
|
} = require("./notifications/conditions");
|
||||||
|
const { createPlaybackService } = require("./playback");
|
||||||
|
const { createStorageService } = require("./storage");
|
||||||
|
|
||||||
|
function listen(httpServer, port, host) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
function onError(error) {
|
||||||
|
httpServer.off("listening", onListening);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
function onListening() {
|
||||||
|
httpServer.off("error", onError);
|
||||||
|
resolve(httpServer.address());
|
||||||
|
}
|
||||||
|
httpServer.once("error", onError);
|
||||||
|
httpServer.once("listening", onListening);
|
||||||
|
httpServer.listen(port, host);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntime({
|
||||||
|
config,
|
||||||
|
imageProcessor: suppliedImageProcessor,
|
||||||
|
logger = console,
|
||||||
|
mqttConnect,
|
||||||
|
notificationFetch,
|
||||||
|
storage: suppliedStorage,
|
||||||
|
}) {
|
||||||
|
const database = openDatabase(config.database);
|
||||||
|
let migrations;
|
||||||
|
try {
|
||||||
|
migrations = runMigrations(database);
|
||||||
|
} catch (error) {
|
||||||
|
database.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycle = {
|
||||||
|
ready: false,
|
||||||
|
stopping: false,
|
||||||
|
};
|
||||||
|
const notifications = createNotificationService({
|
||||||
|
database,
|
||||||
|
logger,
|
||||||
|
webhookUrl: config.notifications.homeAssistantWebhookUrl,
|
||||||
|
webhookSecret: config.notifications.homeAssistantWebhookSecret,
|
||||||
|
webhookTimeoutMs: config.notifications.webhookTimeoutMs,
|
||||||
|
...(notificationFetch ? { fetchFn: notificationFetch } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function safely(action, message) {
|
||||||
|
try {
|
||||||
|
action();
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
logger.warn?.(message, error);
|
||||||
|
} catch {
|
||||||
|
// Integration observation is best effort after an authoritative commit.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let storage;
|
||||||
|
let imageProcessor;
|
||||||
|
try {
|
||||||
|
storage =
|
||||||
|
suppliedStorage ||
|
||||||
|
createStorageService(config.storage, {
|
||||||
|
logger,
|
||||||
|
onHealthChanged(health) {
|
||||||
|
safely(
|
||||||
|
() => observeStorageHealth(notifications, health),
|
||||||
|
"Storage incident observation failed",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
imageProcessor =
|
||||||
|
suppliedImageProcessor ||
|
||||||
|
createImageProcessor({ maxInputPixels: config.processing.maxInputPixels });
|
||||||
|
} catch (error) {
|
||||||
|
database.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
let app;
|
||||||
|
let io = null;
|
||||||
|
let playbackService;
|
||||||
|
let mqttService;
|
||||||
|
let displayService;
|
||||||
|
function emitSocketEvent(eventName, payload) {
|
||||||
|
try {
|
||||||
|
io?.emit(eventName, payload);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
logger.warn?.(`Socket.IO ${eventName} publish failed`, error);
|
||||||
|
} catch {
|
||||||
|
// Socket invalidation remains best effort after the authoritative commit.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
playbackService = createPlaybackService({
|
||||||
|
database,
|
||||||
|
logger,
|
||||||
|
onActivationChanged(event) {
|
||||||
|
emitSocketEvent("playlists:changed", event);
|
||||||
|
},
|
||||||
|
// Phase 3B commit ordering is unchanged: this runs after the playback
|
||||||
|
// transaction has committed, and no integration failure can undo it.
|
||||||
|
onStateChanged(snapshot) {
|
||||||
|
emitSocketEvent("playback:state", snapshot);
|
||||||
|
safely(
|
||||||
|
() => mqttService?.publishState(snapshot),
|
||||||
|
"MQTT playback state publication failed",
|
||||||
|
);
|
||||||
|
safely(
|
||||||
|
() => observePlayback(notifications, snapshot),
|
||||||
|
"Playback incident observation failed",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mqttService = createMqttService({
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
playbackService,
|
||||||
|
...(mqttConnect ? { connect: mqttConnect } : {}),
|
||||||
|
});
|
||||||
|
displayService = createDisplayService({
|
||||||
|
database,
|
||||||
|
logger,
|
||||||
|
notifications,
|
||||||
|
offlineThresholdMs: config.displays.offlineThresholdMs,
|
||||||
|
onStatusChanged(event) {
|
||||||
|
emitSocketEvent("display:status", event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app = createApp({
|
||||||
|
config,
|
||||||
|
database,
|
||||||
|
displayService,
|
||||||
|
imageProcessor,
|
||||||
|
integrations: { mqtt: mqttService, notifications },
|
||||||
|
lifecycle,
|
||||||
|
logger,
|
||||||
|
migrations,
|
||||||
|
onAssetProcessingFailed(failure) {
|
||||||
|
safely(
|
||||||
|
() => observeAssetProcessingFailure(notifications, failure),
|
||||||
|
"Asset processing incident observation failed",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPlaylistsChanged(event) {
|
||||||
|
emitSocketEvent("playlists:changed", event);
|
||||||
|
},
|
||||||
|
playbackService,
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
storage.stop();
|
||||||
|
database.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const httpServer = http.createServer(app);
|
||||||
|
io = new SocketServer(httpServer, {
|
||||||
|
serveClient: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
logger.info?.(`Socket.IO client connected: ${socket.id}`);
|
||||||
|
try {
|
||||||
|
socket.emit("playback:state", playbackService.getSnapshot());
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn?.("Initial playback snapshot could not be sent", error);
|
||||||
|
}
|
||||||
|
// Display telemetry is read-only reporting. It is validated, bounded, and
|
||||||
|
// can never mutate playback or playlist state.
|
||||||
|
socket.on("display:heartbeat", (payload) => {
|
||||||
|
try {
|
||||||
|
displayService.heartbeat(payload);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn?.(
|
||||||
|
`Ignoring malformed display heartbeat from ${socket.id}: ${error?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("disconnect", () => {
|
||||||
|
// A closed socket is not an offline display. Only heartbeat staleness
|
||||||
|
// decides that, because browsers reconnect with a new socket ID.
|
||||||
|
logger.info?.(`Socket.IO client disconnected: ${socket.id}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let address = null;
|
||||||
|
let startPromise = null;
|
||||||
|
let stopPromise = null;
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (stopPromise) {
|
||||||
|
throw new Error("Cannot start a runtime after shutdown has begun");
|
||||||
|
}
|
||||||
|
if (startPromise) {
|
||||||
|
return startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
startPromise = Promise.resolve()
|
||||||
|
.then(() => playbackService.initialize())
|
||||||
|
.then(() => playbackService.start())
|
||||||
|
.then(() => listen(httpServer, config.port, config.host))
|
||||||
|
.then((listeningAddress) => {
|
||||||
|
address = listeningAddress;
|
||||||
|
lifecycle.ready = true;
|
||||||
|
// None of these are readiness-critical. A broker or webhook receiver
|
||||||
|
// that is down must never keep the API from serving playback.
|
||||||
|
notifications.start();
|
||||||
|
displayService.start();
|
||||||
|
safely(() => mqttService.start(), "MQTT integration could not be started");
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => storage.start())
|
||||||
|
.catch(() => {
|
||||||
|
logger.warn?.("Object storage health checks could not be started");
|
||||||
|
});
|
||||||
|
return listeningAddress;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
playbackService.stop();
|
||||||
|
notifications.stop();
|
||||||
|
displayService.stop();
|
||||||
|
storage.stop();
|
||||||
|
database.close();
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(reason = "shutdown") {
|
||||||
|
if (stopPromise) {
|
||||||
|
return stopPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle.ready = false;
|
||||||
|
lifecycle.stopping = true;
|
||||||
|
playbackService.stop();
|
||||||
|
notifications.stop();
|
||||||
|
displayService.stop();
|
||||||
|
const mqttClosed = Promise.resolve()
|
||||||
|
.then(() => mqttService.stop())
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn?.("MQTT shutdown failed", error);
|
||||||
|
});
|
||||||
|
stopPromise = new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
storage.stop();
|
||||||
|
if (database.open) {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
logger.warn?.(
|
||||||
|
`Graceful shutdown timed out after ${config.shutdownTimeoutMs}ms`,
|
||||||
|
);
|
||||||
|
httpServer.closeAllConnections?.();
|
||||||
|
finish();
|
||||||
|
}, config.shutdownTimeoutMs);
|
||||||
|
timeout.unref?.();
|
||||||
|
|
||||||
|
if (!httpServer.listening) {
|
||||||
|
io.close();
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info?.(`Stopping Announcements API (${reason})`);
|
||||||
|
io.close(finish);
|
||||||
|
})
|
||||||
|
// Shutdown waits for MQTT to retract its retained availability so a
|
||||||
|
// planned restart is not reported as an unexpected disappearance.
|
||||||
|
.then(() => mqttClosed);
|
||||||
|
return stopPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
config,
|
||||||
|
database,
|
||||||
|
displayService,
|
||||||
|
httpServer,
|
||||||
|
io,
|
||||||
|
lifecycle,
|
||||||
|
migrations,
|
||||||
|
mqttService,
|
||||||
|
notifications,
|
||||||
|
playbackService,
|
||||||
|
storage,
|
||||||
|
imageProcessor,
|
||||||
|
get address() {
|
||||||
|
return address;
|
||||||
|
},
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRuntime,
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const express = require("express");
|
||||||
|
|
||||||
|
function createSettingsRouter(
|
||||||
|
settingsService,
|
||||||
|
{
|
||||||
|
onBeforePlaybackDomainChange = () => {},
|
||||||
|
onPlaylistsChanged = () => {},
|
||||||
|
onPlaybackDomainChanged = () => {},
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", (request, response) => {
|
||||||
|
response.json(settingsService.get());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/", (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const previous = settingsService.get();
|
||||||
|
onBeforePlaybackDomainChange({ reason: "before_settings_update" });
|
||||||
|
const updated = settingsService.update(request.body);
|
||||||
|
if (updated.revision !== previous.revision) {
|
||||||
|
const change = {
|
||||||
|
reason: "settings_updated",
|
||||||
|
playlistIds: null,
|
||||||
|
settingsRevision: updated.revision,
|
||||||
|
};
|
||||||
|
onPlaybackDomainChanged(change);
|
||||||
|
onPlaylistsChanged(change);
|
||||||
|
}
|
||||||
|
response.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createSettingsRouter,
|
||||||
|
};
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
const SETTING_COLUMNS = Object.freeze({
|
||||||
|
defaultDurationMs: "default_duration_ms",
|
||||||
|
defaultBackgroundMode: "default_background_mode",
|
||||||
|
defaultSolidBackgroundColor: "default_solid_background_color",
|
||||||
|
defaultBackgroundDarkened: "default_background_darkened",
|
||||||
|
defaultBlurPx: "default_blur_px",
|
||||||
|
crossfadeDurationMs: "crossfade_duration_ms",
|
||||||
|
});
|
||||||
|
|
||||||
|
class SettingsValidationError extends Error {
|
||||||
|
constructor(message, details = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SettingsValidationError";
|
||||||
|
this.statusCode = 400;
|
||||||
|
this.code = "INVALID_SETTINGS";
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSettings(row) {
|
||||||
|
return {
|
||||||
|
revision: row.revision,
|
||||||
|
defaultDurationMs: row.default_duration_ms,
|
||||||
|
defaultBackgroundMode: row.default_background_mode,
|
||||||
|
defaultSolidBackgroundColor: row.default_solid_background_color,
|
||||||
|
defaultBackgroundDarkened: Boolean(row.default_background_darkened),
|
||||||
|
defaultBlurPx: row.default_blur_px,
|
||||||
|
crossfadeDurationMs: row.crossfade_duration_ms,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSafeInteger(name, value, { positive = false } = {}) {
|
||||||
|
if (!Number.isSafeInteger(value) || (positive ? value <= 0 : value < 0)) {
|
||||||
|
const qualifier = positive ? "a positive" : "a non-negative";
|
||||||
|
throw new SettingsValidationError(`${name} must be ${qualifier} integer`, {
|
||||||
|
field: name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePatch(input) {
|
||||||
|
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
throw new SettingsValidationError("Settings payload must be a JSON object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(input);
|
||||||
|
const unknown = keys.filter((key) => !Object.hasOwn(SETTING_COLUMNS, key));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
throw new SettingsValidationError("Settings payload contains unknown fields", {
|
||||||
|
fields: unknown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (keys.length === 0) {
|
||||||
|
throw new SettingsValidationError("At least one setting is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("defaultDurationMs" in input) {
|
||||||
|
validateSafeInteger("defaultDurationMs", input.defaultDurationMs, {
|
||||||
|
positive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"defaultBackgroundMode" in input &&
|
||||||
|
!["blurred", "solid", "black"].includes(input.defaultBackgroundMode)
|
||||||
|
) {
|
||||||
|
throw new SettingsValidationError(
|
||||||
|
"defaultBackgroundMode must be blurred, solid, or black",
|
||||||
|
{ field: "defaultBackgroundMode" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"defaultSolidBackgroundColor" in input &&
|
||||||
|
(typeof input.defaultSolidBackgroundColor !== "string" ||
|
||||||
|
!/^#[0-9a-f]{6}$/i.test(input.defaultSolidBackgroundColor))
|
||||||
|
) {
|
||||||
|
throw new SettingsValidationError(
|
||||||
|
"defaultSolidBackgroundColor must be a six-digit hex color",
|
||||||
|
{ field: "defaultSolidBackgroundColor" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"defaultBackgroundDarkened" in input &&
|
||||||
|
typeof input.defaultBackgroundDarkened !== "boolean"
|
||||||
|
) {
|
||||||
|
throw new SettingsValidationError(
|
||||||
|
"defaultBackgroundDarkened must be a boolean",
|
||||||
|
{ field: "defaultBackgroundDarkened" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ("defaultBlurPx" in input) {
|
||||||
|
validateSafeInteger("defaultBlurPx", input.defaultBlurPx);
|
||||||
|
}
|
||||||
|
if ("crossfadeDurationMs" in input) {
|
||||||
|
validateSafeInteger("crossfadeDurationMs", input.crossfadeDurationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSettingsService(database, { now = Date.now } = {}) {
|
||||||
|
const read = database.prepare("SELECT * FROM app_settings WHERE id = 1");
|
||||||
|
const update = database.prepare(`
|
||||||
|
UPDATE app_settings
|
||||||
|
SET revision = revision + 1,
|
||||||
|
default_duration_ms = @defaultDurationMs,
|
||||||
|
default_background_mode = @defaultBackgroundMode,
|
||||||
|
default_solid_background_color = @defaultSolidBackgroundColor,
|
||||||
|
default_background_darkened = @defaultBackgroundDarkened,
|
||||||
|
default_blur_px = @defaultBlurPx,
|
||||||
|
crossfade_duration_ms = @crossfadeDurationMs,
|
||||||
|
updated_at = @updatedAt
|
||||||
|
WHERE id = 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
function get() {
|
||||||
|
const row = read.get();
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("The app_settings singleton row is missing");
|
||||||
|
}
|
||||||
|
return mapSettings(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTransaction = database.transaction((patch) => {
|
||||||
|
validatePatch(patch);
|
||||||
|
const current = get();
|
||||||
|
const next = { ...current, ...patch };
|
||||||
|
const changed = Object.keys(patch).some((key) => current[key] !== next[key]);
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
update.run({
|
||||||
|
defaultDurationMs: next.defaultDurationMs,
|
||||||
|
defaultBackgroundMode: next.defaultBackgroundMode,
|
||||||
|
defaultSolidBackgroundColor: next.defaultSolidBackgroundColor,
|
||||||
|
defaultBackgroundDarkened: next.defaultBackgroundDarkened ? 1 : 0,
|
||||||
|
defaultBlurPx: next.defaultBlurPx,
|
||||||
|
crossfadeDurationMs: next.crossfadeDurationMs,
|
||||||
|
updatedAt: Math.max(now(), current.updatedAt + 1),
|
||||||
|
});
|
||||||
|
return get();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get,
|
||||||
|
update: updateTransaction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SettingsValidationError,
|
||||||
|
createSettingsService,
|
||||||
|
mapSettings,
|
||||||
|
validatePatch,
|
||||||
|
};
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
const {
|
||||||
|
CreateBucketCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} = require("@aws-sdk/client-s3");
|
||||||
|
|
||||||
|
class StorageError extends Error {
|
||||||
|
constructor(message, { code, statusCode, cause } = {}) {
|
||||||
|
super(message, { cause });
|
||||||
|
this.name = "StorageError";
|
||||||
|
this.code = code || "STORAGE_ERROR";
|
||||||
|
this.statusCode = statusCode || 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StorageUnavailableError extends StorageError {
|
||||||
|
constructor(cause) {
|
||||||
|
super("Object storage is unavailable", {
|
||||||
|
code: "STORAGE_UNAVAILABLE",
|
||||||
|
statusCode: 503,
|
||||||
|
cause,
|
||||||
|
});
|
||||||
|
this.name = "StorageUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StorageObjectNotFoundError extends StorageError {
|
||||||
|
constructor(key, cause) {
|
||||||
|
super("Stored object was not found", {
|
||||||
|
code: "STORAGE_OBJECT_NOT_FOUND",
|
||||||
|
statusCode: 404,
|
||||||
|
cause,
|
||||||
|
});
|
||||||
|
this.name = "StorageObjectNotFoundError";
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorStatus(error) {
|
||||||
|
return error?.$metadata?.httpStatusCode || error?.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingBucket(error) {
|
||||||
|
return (
|
||||||
|
error?.name === "NoSuchBucket" ||
|
||||||
|
error?.Code === "NoSuchBucket" ||
|
||||||
|
error?.code === "NoSuchBucket" ||
|
||||||
|
errorStatus(error) === 404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingObject(error) {
|
||||||
|
if (
|
||||||
|
error?.name === "NoSuchBucket" ||
|
||||||
|
error?.Code === "NoSuchBucket" ||
|
||||||
|
error?.code === "NoSuchBucket"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
error?.name === "NoSuchKey" ||
|
||||||
|
error?.name === "NotFound" ||
|
||||||
|
error?.Code === "NoSuchKey" ||
|
||||||
|
error?.code === "NoSuchKey" ||
|
||||||
|
errorStatus(error) === 404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBucketCreationConflict(error) {
|
||||||
|
return (
|
||||||
|
error?.name === "BucketAlreadyOwnedByYou" ||
|
||||||
|
error?.name === "BucketAlreadyExists" ||
|
||||||
|
error?.Code === "BucketAlreadyOwnedByYou" ||
|
||||||
|
error?.Code === "BucketAlreadyExists" ||
|
||||||
|
error?.code === "BucketAlreadyOwnedByYou" ||
|
||||||
|
error?.code === "BucketAlreadyExists"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConsumerCancellation(error) {
|
||||||
|
return (
|
||||||
|
error?.name === "AbortError" ||
|
||||||
|
error?.code === "ABORT_ERR" ||
|
||||||
|
error?.code === "ERR_STREAM_PREMATURE_CLOSE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertObjectKey(key) {
|
||||||
|
if (typeof key !== "string" || key.length === 0) {
|
||||||
|
throw new TypeError("Storage object key must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = key.split("/");
|
||||||
|
if (
|
||||||
|
key.startsWith("/") ||
|
||||||
|
key.includes("\\") ||
|
||||||
|
key.includes("\0") ||
|
||||||
|
segments.some((segment) => segment === "." || segment === "..")
|
||||||
|
) {
|
||||||
|
throw new TypeError("Storage object key is invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicHealth(health) {
|
||||||
|
return Object.freeze({ ...health });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createS3Client(config) {
|
||||||
|
const options = {
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
region: config.region,
|
||||||
|
forcePathStyle: config.forcePathStyle,
|
||||||
|
maxAttempts: config.maxAttempts,
|
||||||
|
requestHandler: {
|
||||||
|
connectionTimeout: config.connectionTimeoutMs,
|
||||||
|
// Use an inactivity timeout rather than an absolute request deadline so
|
||||||
|
// a large but continuously progressing upload is not aborted.
|
||||||
|
socketTimeout: config.requestTimeoutMs,
|
||||||
|
},
|
||||||
|
// Optional AWS checksums are not uniformly implemented by S3-compatible
|
||||||
|
// providers. Source and derivative SHA-256 hashes are verified by the app.
|
||||||
|
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||||
|
responseChecksumValidation: "WHEN_REQUIRED",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.accessKeyId && config.secretAccessKey) {
|
||||||
|
options.credentials = {
|
||||||
|
accessKeyId: config.accessKeyId,
|
||||||
|
secretAccessKey: config.secretAccessKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new S3Client(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStorageService(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
logger = console,
|
||||||
|
now = () => new Date(),
|
||||||
|
onHealthChanged = () => {},
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const s3 = client || createS3Client(config);
|
||||||
|
let ensurePromise = null;
|
||||||
|
let healthTimer = null;
|
||||||
|
let bucketReady = false;
|
||||||
|
let issuedHealthSequence = 0;
|
||||||
|
let appliedHealthSequence = 0;
|
||||||
|
let currentHealth = {
|
||||||
|
status: "degraded",
|
||||||
|
checkedAt: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
code: "STORAGE_NOT_CHECKED",
|
||||||
|
message: "Object storage has not been checked",
|
||||||
|
};
|
||||||
|
|
||||||
|
function nextHealthSequence() {
|
||||||
|
issuedHealthSequence += 1;
|
||||||
|
return issuedHealthSequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a real status transition is reported. Many requests observing the
|
||||||
|
// same outage must not become many notifications.
|
||||||
|
function announceHealth(previousStatus) {
|
||||||
|
if (previousStatus === currentHealth.status) return;
|
||||||
|
try {
|
||||||
|
onHealthChanged(publicHealth(currentHealth));
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
logger.warn?.("Storage health observer failed", error);
|
||||||
|
} catch {
|
||||||
|
// Observability must never break a storage operation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markHealthy(sequence) {
|
||||||
|
if (sequence < appliedHealthSequence) return publicHealth(currentHealth);
|
||||||
|
appliedHealthSequence = sequence;
|
||||||
|
bucketReady = true;
|
||||||
|
const previousStatus = currentHealth.status;
|
||||||
|
const checkedAt = now().toISOString();
|
||||||
|
currentHealth = {
|
||||||
|
status: "healthy",
|
||||||
|
checkedAt,
|
||||||
|
lastSuccessAt: checkedAt,
|
||||||
|
};
|
||||||
|
announceHealth(previousStatus);
|
||||||
|
return publicHealth(currentHealth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDegraded(sequence) {
|
||||||
|
if (sequence < appliedHealthSequence) return publicHealth(currentHealth);
|
||||||
|
appliedHealthSequence = sequence;
|
||||||
|
bucketReady = false;
|
||||||
|
const previousStatus = currentHealth.status;
|
||||||
|
currentHealth = {
|
||||||
|
status: "degraded",
|
||||||
|
checkedAt: now().toISOString(),
|
||||||
|
lastSuccessAt: currentHealth.lastSuccessAt,
|
||||||
|
code: "STORAGE_UNAVAILABLE",
|
||||||
|
message: "Object storage is unavailable",
|
||||||
|
};
|
||||||
|
announceHealth(previousStatus);
|
||||||
|
return publicHealth(currentHealth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailable(error, sequence) {
|
||||||
|
markDegraded(sequence);
|
||||||
|
return error instanceof StorageUnavailableError
|
||||||
|
? error
|
||||||
|
: new StorageUnavailableError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBucketOnce() {
|
||||||
|
const sequence = nextHealthSequence();
|
||||||
|
try {
|
||||||
|
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
|
||||||
|
markHealthy(sequence);
|
||||||
|
return { created: false };
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingBucket(error)) {
|
||||||
|
throw unavailable(error, sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3.send(new CreateBucketCommand({ Bucket: config.bucket }));
|
||||||
|
markHealthy(sequence);
|
||||||
|
return { created: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (isBucketCreationConflict(error)) {
|
||||||
|
try {
|
||||||
|
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
|
||||||
|
markHealthy(sequence);
|
||||||
|
return { created: false };
|
||||||
|
} catch (verificationError) {
|
||||||
|
throw unavailable(verificationError, sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw unavailable(error, sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBucket() {
|
||||||
|
if (!ensurePromise) {
|
||||||
|
ensurePromise = ensureBucketOnce().finally(() => {
|
||||||
|
ensurePromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ensurePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
try {
|
||||||
|
await ensureBucket();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn?.("Object storage is unavailable during initialization");
|
||||||
|
}
|
||||||
|
return publicHealth(currentHealth);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (!healthTimer) {
|
||||||
|
healthTimer = setInterval(() => {
|
||||||
|
ensureBucket().catch(() => {});
|
||||||
|
}, config.healthProbeIntervalMs || 30_000);
|
||||||
|
healthTimer.unref?.();
|
||||||
|
}
|
||||||
|
return initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe() {
|
||||||
|
const sequence = nextHealthSequence();
|
||||||
|
try {
|
||||||
|
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
|
||||||
|
return markHealthy(sequence);
|
||||||
|
} catch (error) {
|
||||||
|
return markDegraded(sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putObject({ key, body, contentType, contentLength, metadata }) {
|
||||||
|
assertObjectKey(key);
|
||||||
|
if (!bucketReady) await ensureBucket();
|
||||||
|
const sequence = nextHealthSequence();
|
||||||
|
const input = {
|
||||||
|
Bucket: config.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
...(contentType ? { ContentType: contentType } : {}),
|
||||||
|
...(contentLength !== undefined ? { ContentLength: contentLength } : {}),
|
||||||
|
...(metadata ? { Metadata: metadata } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await s3.send(new PutObjectCommand(input));
|
||||||
|
markHealthy(sequence);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
etag: result.ETag || null,
|
||||||
|
versionId: result.VersionId || null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw unavailable(error, sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectMetadata(key, result) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
contentType: result.ContentType || null,
|
||||||
|
contentLength:
|
||||||
|
result.ContentLength === undefined ? null : Number(result.ContentLength),
|
||||||
|
etag: result.ETag || null,
|
||||||
|
lastModified: result.LastModified || null,
|
||||||
|
metadata: result.Metadata || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getObject(key) {
|
||||||
|
assertObjectKey(key);
|
||||||
|
if (!bucketReady) await ensureBucket();
|
||||||
|
const sequence = nextHealthSequence();
|
||||||
|
try {
|
||||||
|
const result = await s3.send(
|
||||||
|
new GetObjectCommand({ Bucket: config.bucket, Key: key }),
|
||||||
|
);
|
||||||
|
markHealthy(sequence);
|
||||||
|
result.Body?.once?.("error", (error) => {
|
||||||
|
if (!isConsumerCancellation(error)) {
|
||||||
|
markDegraded(nextHealthSequence());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...objectMetadata(key, result),
|
||||||
|
body: result.Body,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingObject(error)) {
|
||||||
|
markHealthy(sequence);
|
||||||
|
throw new StorageObjectNotFoundError(key, error);
|
||||||
|
}
|
||||||
|
throw unavailable(error, sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function headObject(key) {
|
||||||
|
assertObjectKey(key);
|
||||||
|
if (!bucketReady) await ensureBucket();
|
||||||
|
const sequence = nextHealthSequence();
|
||||||
|
try {
|
||||||
|
const result = await s3.send(
|
||||||
|
new HeadObjectCommand({ Bucket: config.bucket, Key: key }),
|
||||||
|
);
|
||||||
|
markHealthy(sequence);
|
||||||
|
return objectMetadata(key, result);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingObject(error)) {
|
||||||
|
markHealthy(sequence);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw unavailable(error, sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function objectExists(key) {
|
||||||
|
return (await headObject(key)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteObject(key) {
|
||||||
|
assertObjectKey(key);
|
||||||
|
if (!bucketReady) await ensureBucket();
|
||||||
|
const sequence = nextHealthSequence();
|
||||||
|
try {
|
||||||
|
await s3.send(
|
||||||
|
new DeleteObjectCommand({ Bucket: config.bucket, Key: key }),
|
||||||
|
);
|
||||||
|
markHealthy(sequence);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingObject(error)) {
|
||||||
|
markHealthy(sequence);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw unavailable(error, sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (healthTimer) {
|
||||||
|
clearInterval(healthTimer);
|
||||||
|
healthTimer = null;
|
||||||
|
}
|
||||||
|
s3.destroy?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
initialize,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
ensureBucket,
|
||||||
|
probe,
|
||||||
|
health: () => publicHealth(currentHealth),
|
||||||
|
putObject,
|
||||||
|
getObject,
|
||||||
|
headObject,
|
||||||
|
objectExists,
|
||||||
|
deleteObject,
|
||||||
|
destroy: stop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
StorageError,
|
||||||
|
StorageObjectNotFoundError,
|
||||||
|
StorageUnavailableError,
|
||||||
|
createS3Client,
|
||||||
|
createStorageService,
|
||||||
|
};
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const http = require("node:http");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { Readable } = require("node:stream");
|
||||||
|
const sharp = require("sharp");
|
||||||
|
const { createApp } = require("../src/app");
|
||||||
|
const { loadConfig } = require("../src/config");
|
||||||
|
const { openDatabase } = require("../src/database");
|
||||||
|
const { runMigrations } = require("../src/database/migrate");
|
||||||
|
const { createImageProcessor } = require("../src/image-processing");
|
||||||
|
const { StorageObjectNotFoundError } = require("../src/storage");
|
||||||
|
|
||||||
|
async function readBody(body) {
|
||||||
|
if (Buffer.isBuffer(body)) return body;
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryStorage() {
|
||||||
|
const objects = new Map();
|
||||||
|
return {
|
||||||
|
objects,
|
||||||
|
health() {
|
||||||
|
return {
|
||||||
|
status: "healthy",
|
||||||
|
checkedAt: "2033-05-18T03:33:20.000Z",
|
||||||
|
lastSuccessAt: "2033-05-18T03:33:20.000Z",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async putObject(input) {
|
||||||
|
objects.set(input.key, {
|
||||||
|
body: await readBody(input.body),
|
||||||
|
contentType: input.contentType,
|
||||||
|
metadata: input.metadata,
|
||||||
|
});
|
||||||
|
return { key: input.key };
|
||||||
|
},
|
||||||
|
async getObject(key) {
|
||||||
|
const object = objects.get(key);
|
||||||
|
if (!object) throw new StorageObjectNotFoundError(key);
|
||||||
|
return {
|
||||||
|
body: Readable.from([object.body]),
|
||||||
|
contentLength: object.body.length,
|
||||||
|
contentType: object.contentType,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async deleteObject(key) {
|
||||||
|
objects.delete(key);
|
||||||
|
return { key, deleted: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createHarness(t, { maxFileBytes = 1024 * 1024 } = {}) {
|
||||||
|
const tempDirectory = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "announcements-api-uploads-"),
|
||||||
|
);
|
||||||
|
const config = loadConfig({
|
||||||
|
NODE_ENV: "test",
|
||||||
|
HOST: "127.0.0.1",
|
||||||
|
PORT: "0",
|
||||||
|
DATABASE_PATH: ":memory:",
|
||||||
|
APP_TIMEZONE: "America/Chicago",
|
||||||
|
S3_ENDPOINT: "http://private-seaweedfs:8333",
|
||||||
|
S3_BUCKET: "announcements-test",
|
||||||
|
UPLOAD_TEMP_DIRECTORY: tempDirectory,
|
||||||
|
UPLOAD_MAX_FILE_BYTES: String(maxFileBytes),
|
||||||
|
UPLOAD_MAX_FILES: "5",
|
||||||
|
IMAGE_MAX_INPUT_PIXELS: "1000000",
|
||||||
|
});
|
||||||
|
const database = openDatabase({ path: ":memory:" });
|
||||||
|
const migrations = runMigrations(database);
|
||||||
|
const storage = memoryStorage();
|
||||||
|
const app = createApp({
|
||||||
|
config,
|
||||||
|
database,
|
||||||
|
imageProcessor: createImageProcessor({
|
||||||
|
maxInputPixels: config.processing.maxInputPixels,
|
||||||
|
}),
|
||||||
|
lifecycle: { ready: true },
|
||||||
|
migrations,
|
||||||
|
storage,
|
||||||
|
});
|
||||||
|
const server = app.listen(0, "127.0.0.1");
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once("listening", resolve);
|
||||||
|
server.once("error", reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
database.close();
|
||||||
|
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: `http://127.0.0.1:${server.address().port}`,
|
||||||
|
database,
|
||||||
|
storage,
|
||||||
|
tempDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFile(form, field, buffer, filename, contentType) {
|
||||||
|
form.append(field, new Blob([buffer], { type: contentType }), filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jsonResponse(baseUrl, route, init) {
|
||||||
|
const response = await fetch(`${baseUrl}${route}`, init);
|
||||||
|
return { response, body: await response.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eventuallyEmpty(directory) {
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
if (fs.readdirSync(directory).length === 0) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
assert.deepEqual(fs.readdirSync(directory), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eventuallyContainsFile(directory) {
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
if (fs.readdirSync(directory).length > 0) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
assert.fail("The upload did not create its temporary file");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sampleImages() {
|
||||||
|
const source = sharp({
|
||||||
|
create: {
|
||||||
|
width: 80,
|
||||||
|
height: 60,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 31, g: 72, b: 120, alpha: 0.5 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
jpeg: await source.clone().jpeg().toBuffer(),
|
||||||
|
png: await source.clone().png().toBuffer(),
|
||||||
|
webp: await source.clone().webp().toBuffer(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("asset HTTP API accepts JPEG, PNG, and WebP and serves private immutable media", async (t) => {
|
||||||
|
const harness = await createHarness(t);
|
||||||
|
const images = await sampleImages();
|
||||||
|
const form = new FormData();
|
||||||
|
appendFile(form, "files", images.jpeg, "welcome.jpg", "image/jpeg");
|
||||||
|
appendFile(form, "files", images.png, "../../bulletin.png", "image/png");
|
||||||
|
appendFile(form, "files", images.webp, "events.webp", "image/webp");
|
||||||
|
|
||||||
|
const uploaded = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
assert.equal(uploaded.response.status, 201);
|
||||||
|
assert.equal(uploaded.body.createdCount, 3);
|
||||||
|
assert.equal(uploaded.body.duplicateCount, 0);
|
||||||
|
assert.equal(uploaded.body.results.length, 3);
|
||||||
|
assert.deepEqual(
|
||||||
|
uploaded.body.results.map((result) => result.asset.original.contentType),
|
||||||
|
["image/jpeg", "image/png", "image/webp"],
|
||||||
|
);
|
||||||
|
assert.equal(uploaded.body.results[1].asset.originalFilename, "bulletin.png");
|
||||||
|
assert.doesNotMatch(JSON.stringify(uploaded.body), /objectKey|object_key|private-seaweedfs/);
|
||||||
|
assert.equal(harness.storage.objects.size, 9);
|
||||||
|
await eventuallyEmpty(harness.tempDirectory);
|
||||||
|
|
||||||
|
const asset = uploaded.body.results[0].asset;
|
||||||
|
const listed = await jsonResponse(harness.baseUrl, "/api/assets");
|
||||||
|
assert.equal(listed.response.status, 200);
|
||||||
|
assert.equal(listed.body.assets.length, 3);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await jsonResponse(harness.baseUrl, `/api/assets/${asset.id}`)).body,
|
||||||
|
asset,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const media of [asset.display, asset.thumbnail]) {
|
||||||
|
const response = await fetch(`${harness.baseUrl}${media.url}`);
|
||||||
|
const body = Buffer.from(await response.arrayBuffer());
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(response.headers.get("content-type"), "image/webp");
|
||||||
|
assert.equal(
|
||||||
|
response.headers.get("cache-control"),
|
||||||
|
"public, max-age=31536000, immutable",
|
||||||
|
);
|
||||||
|
assert.equal(response.headers.get("content-length"), String(body.length));
|
||||||
|
assert.equal(response.headers.get("etag"), `"${media.sha256}"`);
|
||||||
|
assert.equal(crypto.createHash("sha256").update(body).digest("hex"), media.sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = await fetch(`${harness.baseUrl}${asset.original.url}`);
|
||||||
|
assert.equal(original.status, 200);
|
||||||
|
assert.equal(original.headers.get("content-type"), "image/jpeg");
|
||||||
|
assert.equal(original.headers.get("cache-control"), "private, no-store");
|
||||||
|
assert.match(original.headers.get("content-disposition"), /^attachment;/);
|
||||||
|
assert.deepEqual(Buffer.from(await original.arrayBuffer()), images.jpeg);
|
||||||
|
|
||||||
|
const wrongVersion = await jsonResponse(
|
||||||
|
harness.baseUrl,
|
||||||
|
`/media/assets/${asset.id}/display/${"a".repeat(64)}.webp`,
|
||||||
|
);
|
||||||
|
assert.equal(wrongVersion.response.status, 404);
|
||||||
|
assert.equal(wrongVersion.body.error.code, "MEDIA_NOT_FOUND");
|
||||||
|
const arbitraryKey = await jsonResponse(
|
||||||
|
harness.baseUrl,
|
||||||
|
`/media/assets/${asset.id}/display/${encodeURIComponent("../../original/key")}.webp`,
|
||||||
|
);
|
||||||
|
assert.equal(arbitraryKey.response.status, 404);
|
||||||
|
assert.equal(arbitraryKey.body.error.code, "MEDIA_NOT_FOUND");
|
||||||
|
|
||||||
|
const displayKey = [...harness.storage.objects.keys()].find(
|
||||||
|
(key) => key.includes(`assets/${asset.id}/display/`),
|
||||||
|
);
|
||||||
|
harness.storage.objects.delete(displayKey);
|
||||||
|
const missingStoredBytes = await jsonResponse(
|
||||||
|
harness.baseUrl,
|
||||||
|
asset.display.url,
|
||||||
|
);
|
||||||
|
assert.equal(missingStoredBytes.response.status, 503);
|
||||||
|
assert.equal(missingStoredBytes.body.error.code, "STORAGE_INCONSISTENCY");
|
||||||
|
assert.equal(
|
||||||
|
(await jsonResponse(harness.baseUrl, "/health/live")).response.status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asset HTTP API reports duplicates, name conflicts, and persists editable metadata", async (t) => {
|
||||||
|
const harness = await createHarness(t);
|
||||||
|
const { png, jpeg } = await sampleImages();
|
||||||
|
const firstForm = new FormData();
|
||||||
|
appendFile(firstForm, "file", png, "weekly.png", "application/octet-stream");
|
||||||
|
firstForm.append("display_name", "Weekly Announcement");
|
||||||
|
const first = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: firstForm,
|
||||||
|
});
|
||||||
|
assert.equal(first.response.status, 201);
|
||||||
|
assert.equal(first.body.created, true);
|
||||||
|
const assetId = first.body.asset.id;
|
||||||
|
|
||||||
|
const duplicateForm = new FormData();
|
||||||
|
appendFile(duplicateForm, "file", png, "unrelated-name.png", "image/png");
|
||||||
|
duplicateForm.append("display_name", "Ignored duplicate proposal");
|
||||||
|
const duplicate = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: duplicateForm,
|
||||||
|
});
|
||||||
|
assert.equal(duplicate.response.status, 200);
|
||||||
|
assert.equal(duplicate.body.created, false);
|
||||||
|
assert.equal(duplicate.body.duplicate, true);
|
||||||
|
assert.equal(duplicate.body.asset.id, assetId);
|
||||||
|
assert.equal(harness.storage.objects.size, 3);
|
||||||
|
|
||||||
|
const conflictForm = new FormData();
|
||||||
|
appendFile(conflictForm, "file", jpeg, "different.jpg", "image/jpeg");
|
||||||
|
conflictForm.append("displayName", "weekly announcement");
|
||||||
|
const conflict = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: conflictForm,
|
||||||
|
});
|
||||||
|
assert.equal(conflict.response.status, 409);
|
||||||
|
assert.equal(conflict.body.error.code, "ASSET_NAME_CONFLICT");
|
||||||
|
|
||||||
|
const expiresAt = Date.parse("2034-01-02T03:04:05.000Z");
|
||||||
|
const edited = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
display_name: "Renamed announcement",
|
||||||
|
expiration_at: expiresAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(edited.response.status, 200);
|
||||||
|
assert.equal(edited.body.displayName, "Renamed announcement");
|
||||||
|
assert.equal(edited.body.expirationAt, expiresAt);
|
||||||
|
const permanent = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ expiration_at: null }),
|
||||||
|
});
|
||||||
|
assert.equal(permanent.body.expirationAt, null);
|
||||||
|
assert.equal(harness.storage.objects.size, 3);
|
||||||
|
await eventuallyEmpty(harness.tempDirectory);
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
harness.database
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.run("api-playlist", "API playlist", timestamp, timestamp);
|
||||||
|
harness.database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO playlist_entries
|
||||||
|
(id, playlist_id, asset_id, position, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 0, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run("api-entry", "api-playlist", assetId, timestamp, timestamp);
|
||||||
|
const references = await jsonResponse(
|
||||||
|
harness.baseUrl,
|
||||||
|
`/api/assets/${assetId}/references`,
|
||||||
|
);
|
||||||
|
assert.equal(references.response.status, 200);
|
||||||
|
assert.equal(references.body.canDelete, false);
|
||||||
|
assert.equal(references.body.playlists[0].playlistEntryId, "api-entry");
|
||||||
|
const blockedDelete = await jsonResponse(
|
||||||
|
harness.baseUrl,
|
||||||
|
`/api/assets/${assetId}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
assert.equal(blockedDelete.response.status, 409);
|
||||||
|
assert.equal(blockedDelete.body.error.code, "ASSET_REFERENCED");
|
||||||
|
assert.equal(harness.storage.objects.size, 3);
|
||||||
|
harness.database
|
||||||
|
.prepare("DELETE FROM playlist_entries WHERE id = ?")
|
||||||
|
.run("api-entry");
|
||||||
|
|
||||||
|
const removed = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
assert.equal(removed.response.status, 200);
|
||||||
|
assert.deepEqual(removed.body, { id: assetId, deleted: true });
|
||||||
|
assert.equal(harness.storage.objects.size, 0);
|
||||||
|
assert.equal(
|
||||||
|
(await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`)).response.status,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upload validation returns structured errors and always cleans temporary files", async (t) => {
|
||||||
|
const harness = await createHarness(t, { maxFileBytes: 1024 });
|
||||||
|
|
||||||
|
const malformed = new FormData();
|
||||||
|
appendFile(malformed, "file", Buffer.from("not an image"), "broken.png", "image/png");
|
||||||
|
const malformedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: malformed,
|
||||||
|
});
|
||||||
|
assert.equal(malformedResult.response.status, 422);
|
||||||
|
assert.equal(malformedResult.body.error.code, "MALFORMED_IMAGE");
|
||||||
|
await eventuallyEmpty(harness.tempDirectory);
|
||||||
|
|
||||||
|
const unsupportedBuffer = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
channels: 3,
|
||||||
|
background: "red",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.gif()
|
||||||
|
.toBuffer();
|
||||||
|
const unsupported = new FormData();
|
||||||
|
appendFile(unsupported, "file", unsupportedBuffer, "still.gif", "image/png");
|
||||||
|
const unsupportedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: unsupported,
|
||||||
|
});
|
||||||
|
assert.equal(unsupportedResult.response.status, 415);
|
||||||
|
assert.equal(unsupportedResult.body.error.code, "UNSUPPORTED_IMAGE_FORMAT");
|
||||||
|
await eventuallyEmpty(harness.tempDirectory);
|
||||||
|
|
||||||
|
const oversized = new FormData();
|
||||||
|
appendFile(
|
||||||
|
oversized,
|
||||||
|
"file",
|
||||||
|
crypto.randomBytes(1025),
|
||||||
|
"too-large.png",
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
const oversizedResult = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: oversized,
|
||||||
|
});
|
||||||
|
assert.equal(oversizedResult.response.status, 413);
|
||||||
|
assert.equal(oversizedResult.body.error.code, "UPLOAD_FILE_TOO_LARGE");
|
||||||
|
assert.deepEqual(oversizedResult.body.error.details, { maxFileBytes: 1024 });
|
||||||
|
await eventuallyEmpty(harness.tempDirectory);
|
||||||
|
|
||||||
|
const tooMany = new FormData();
|
||||||
|
for (let index = 0; index < 6; index += 1) {
|
||||||
|
appendFile(tooMany, "files", Buffer.from("x"), `${index}.png`, "image/png");
|
||||||
|
}
|
||||||
|
const tooManyResult = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: tooMany,
|
||||||
|
});
|
||||||
|
assert.equal(tooManyResult.response.status, 413);
|
||||||
|
assert.equal(tooManyResult.body.error.code, "UPLOAD_FILE_COUNT_EXCEEDED");
|
||||||
|
assert.deepEqual(tooManyResult.body.error.details, { maxFiles: 5 });
|
||||||
|
await eventuallyEmpty(harness.tempDirectory);
|
||||||
|
|
||||||
|
const absent = await jsonResponse(harness.baseUrl, "/api/assets", {
|
||||||
|
method: "POST",
|
||||||
|
body: new FormData(),
|
||||||
|
});
|
||||||
|
assert.equal(absent.response.status, 400);
|
||||||
|
assert.equal(absent.body.error.code, "UPLOAD_FILE_REQUIRED");
|
||||||
|
assert.equal(harness.storage.objects.size, 0);
|
||||||
|
assert.equal(
|
||||||
|
harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an aborted multipart request removes its in-flight temporary file", async (t) => {
|
||||||
|
const harness = await createHarness(t);
|
||||||
|
const boundary = `announcements-${crypto.randomUUID()}`;
|
||||||
|
const preamble = Buffer.from(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="aborted.png"\r\n' +
|
||||||
|
"Content-Type: image/png\r\n\r\n",
|
||||||
|
);
|
||||||
|
const declaredLength = preamble.length + 512 * 1024;
|
||||||
|
const request = http.request(`${harness.baseUrl}/api/assets`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||||
|
"content-length": String(declaredLength),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
request.on("error", () => {});
|
||||||
|
request.write(preamble);
|
||||||
|
request.write(Buffer.alloc(64 * 1024, 1));
|
||||||
|
|
||||||
|
await eventuallyContainsFile(harness.tempDirectory);
|
||||||
|
const closed = new Promise((resolve) => request.once("close", resolve));
|
||||||
|
request.destroy();
|
||||||
|
await closed;
|
||||||
|
await eventuallyEmpty(harness.tempDirectory);
|
||||||
|
assert.equal(harness.storage.objects.size, 0);
|
||||||
|
assert.equal(
|
||||||
|
harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const {
|
||||||
|
mapMulterError,
|
||||||
|
safeOriginalFilename,
|
||||||
|
} = require("../src/assets/multipart");
|
||||||
|
|
||||||
|
test("original filename metadata keeps a safe leaf without path traversal", () => {
|
||||||
|
assert.equal(safeOriginalFilename("../../Sunday/welcome.png"), "welcome.png");
|
||||||
|
assert.equal(safeOriginalFilename("..\\..\\Sunday\\welcome.png"), "welcome.png");
|
||||||
|
assert.equal(safeOriginalFilename(" welcome sign.png "), " welcome sign.png ");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("original filename metadata removes controls and remains bounded", () => {
|
||||||
|
assert.equal(safeOriginalFilename("wel\u0000come\n.png"), "welcome.png");
|
||||||
|
assert.equal([...safeOriginalFilename(`${"a".repeat(300)}.png`)].length, 255);
|
||||||
|
assert.throws(
|
||||||
|
() => safeOriginalFilename(" \u0000\n "),
|
||||||
|
(error) => error.code === "INVALID_ORIGINAL_FILENAME",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("temporary-storage failures are server errors, not invalid client uploads", () => {
|
||||||
|
const storageFailure = mapMulterError(
|
||||||
|
Object.assign(new Error("disk full at a private path"), { code: "ENOSPC" }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
assert.equal(storageFailure.code, "UPLOAD_TEMP_STORAGE_UNAVAILABLE");
|
||||||
|
assert.equal(storageFailure.statusCode, 503);
|
||||||
|
assert.doesNotMatch(storageFailure.message, /private path/);
|
||||||
|
|
||||||
|
const malformed = mapMulterError(new Error("Unexpected end of form"), {});
|
||||||
|
assert.equal(malformed.code, "INVALID_MULTIPART_UPLOAD");
|
||||||
|
assert.equal(malformed.statusCode, 400);
|
||||||
|
});
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { Readable } = require("node:stream");
|
||||||
|
const { openDatabase } = require("../src/database");
|
||||||
|
const { runMigrations } = require("../src/database/migrate");
|
||||||
|
const { createAssetService } = require("../src/assets/service");
|
||||||
|
const {
|
||||||
|
StorageObjectNotFoundError,
|
||||||
|
StorageUnavailableError,
|
||||||
|
} = require("../src/storage");
|
||||||
|
|
||||||
|
async function bufferBody(body) {
|
||||||
|
if (Buffer.isBuffer(body)) return body;
|
||||||
|
if (body instanceof Uint8Array) return Buffer.from(body);
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of body) chunks.push(Buffer.from(chunk));
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemoryStorage() {
|
||||||
|
const objects = new Map();
|
||||||
|
const calls = [];
|
||||||
|
let failNextPut = null;
|
||||||
|
let successfulPutsBeforeFailure = 0;
|
||||||
|
let failNextDelete = null;
|
||||||
|
let putDelayMs = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
objects,
|
||||||
|
calls,
|
||||||
|
setPutFailure(error, putsBeforeFailure = 0) {
|
||||||
|
failNextPut = error;
|
||||||
|
successfulPutsBeforeFailure = putsBeforeFailure;
|
||||||
|
},
|
||||||
|
setDeleteFailure(error) {
|
||||||
|
failNextDelete = error;
|
||||||
|
},
|
||||||
|
setPutDelay(milliseconds) {
|
||||||
|
putDelayMs = milliseconds;
|
||||||
|
},
|
||||||
|
async putObject(input) {
|
||||||
|
calls.push({ operation: "put", ...input, body: undefined });
|
||||||
|
if (putDelayMs) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, putDelayMs));
|
||||||
|
}
|
||||||
|
if (failNextPut) {
|
||||||
|
if (successfulPutsBeforeFailure === 0) {
|
||||||
|
const error = failNextPut;
|
||||||
|
failNextPut = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
successfulPutsBeforeFailure -= 1;
|
||||||
|
}
|
||||||
|
objects.set(input.key, {
|
||||||
|
body: await bufferBody(input.body),
|
||||||
|
contentType: input.contentType,
|
||||||
|
metadata: input.metadata,
|
||||||
|
});
|
||||||
|
return { key: input.key };
|
||||||
|
},
|
||||||
|
async getObject(key) {
|
||||||
|
calls.push({ operation: "get", key });
|
||||||
|
const object = objects.get(key);
|
||||||
|
if (!object) throw new StorageObjectNotFoundError(key);
|
||||||
|
return {
|
||||||
|
body: Readable.from(object.body),
|
||||||
|
contentLength: object.body.length,
|
||||||
|
contentType: object.contentType,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async deleteObject(key) {
|
||||||
|
calls.push({ operation: "delete", key });
|
||||||
|
if (failNextDelete) {
|
||||||
|
const error = failNextDelete;
|
||||||
|
failNextDelete = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
objects.delete(key);
|
||||||
|
return { key, deleted: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeImageProcessor({ reject } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
async process(filename) {
|
||||||
|
calls.push(filename);
|
||||||
|
if (reject) throw reject;
|
||||||
|
const original = await fs.promises.readFile(filename);
|
||||||
|
const sourceSha256 = crypto.createHash("sha256").update(original).digest("hex");
|
||||||
|
const displayBuffer = Buffer.from(`display:${sourceSha256}`);
|
||||||
|
const thumbnailBuffer = Buffer.from(`thumbnail:${sourceSha256}`);
|
||||||
|
return {
|
||||||
|
original: {
|
||||||
|
sha256: sourceSha256,
|
||||||
|
contentType: "image/png",
|
||||||
|
byteSize: original.length,
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
buffer: displayBuffer,
|
||||||
|
sha256: crypto.createHash("sha256").update(displayBuffer).digest("hex"),
|
||||||
|
contentType: "image/webp",
|
||||||
|
byteSize: displayBuffer.length,
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
buffer: thumbnailBuffer,
|
||||||
|
sha256: crypto.createHash("sha256").update(thumbnailBuffer).digest("hex"),
|
||||||
|
contentType: "image/webp",
|
||||||
|
byteSize: thumbnailBuffer.length,
|
||||||
|
width: 405,
|
||||||
|
height: 270,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness(options = {}) {
|
||||||
|
const database = openDatabase({ path: ":memory:" });
|
||||||
|
runMigrations(database);
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-assets-"));
|
||||||
|
const storage = options.storage || createMemoryStorage();
|
||||||
|
const imageProcessor =
|
||||||
|
options.imageProcessor || createFakeImageProcessor(options.processorOptions);
|
||||||
|
let idCounter = 0;
|
||||||
|
let clock = 2_000_000_000_000;
|
||||||
|
const service = createAssetService({
|
||||||
|
database,
|
||||||
|
storage,
|
||||||
|
imageProcessor,
|
||||||
|
now: () => ++clock,
|
||||||
|
createId:
|
||||||
|
options.createId ||
|
||||||
|
(() => `00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}`),
|
||||||
|
concurrentUploadWaitMs: options.concurrentUploadWaitMs ?? 2_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
function uploadFile(originalFilename, contents) {
|
||||||
|
const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
||||||
|
const filename = path.join(directory, crypto.randomUUID());
|
||||||
|
fs.writeFileSync(filename, buffer);
|
||||||
|
return { path: filename, byteSize: buffer.length, originalFilename };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
database,
|
||||||
|
directory,
|
||||||
|
imageProcessor,
|
||||||
|
service,
|
||||||
|
storage,
|
||||||
|
uploadFile,
|
||||||
|
close() {
|
||||||
|
database.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawAsset(database, assetId) {
|
||||||
|
return database.prepare("SELECT * FROM assets WHERE id = ?").get(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("upload stores exact bytes under content-addressed keys without leaking keys", async (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.close());
|
||||||
|
const original = Buffer.from("exact uploaded bytes");
|
||||||
|
|
||||||
|
const [result] = await harness.service.upload([
|
||||||
|
harness.uploadFile(" Sunday Welcome.PNG ", original),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.created, true);
|
||||||
|
assert.equal(result.duplicate, false);
|
||||||
|
assert.match(result.asset.id, /^[0-9a-f-]{36}$/);
|
||||||
|
assert.equal(result.asset.displayName, "Sunday Welcome");
|
||||||
|
assert.equal(result.asset.originalFilename, " Sunday Welcome.PNG ");
|
||||||
|
assert.equal(result.asset.expirationAt, null);
|
||||||
|
assert.equal(result.asset.status, "ready");
|
||||||
|
assert.equal(result.asset.original.byteSize, original.length);
|
||||||
|
assert.equal(result.asset.display.contentType, "image/webp");
|
||||||
|
assert.equal(result.asset.thumbnail.width, 405);
|
||||||
|
assert.match(result.asset.display.url, /^\/media\/assets\//);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
JSON.stringify(result.asset),
|
||||||
|
/object_key|originalObjectKey|displayObjectKey|thumbnailObjectKey|seaweed/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = rawAsset(harness.database, result.asset.id);
|
||||||
|
const expectedSourceHash = crypto.createHash("sha256").update(original).digest("hex");
|
||||||
|
assert.equal(row.source_sha256, expectedSourceHash);
|
||||||
|
assert.equal(row.original_object_key, `assets/${row.id}/original/${expectedSourceHash}`);
|
||||||
|
assert.equal(
|
||||||
|
row.display_object_key,
|
||||||
|
`assets/${row.id}/display/${row.display_sha256}.webp`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
row.thumbnail_object_key,
|
||||||
|
`assets/${row.id}/thumbnail/${row.thumbnail_sha256}.webp`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(harness.storage.objects.get(row.original_object_key).body, original);
|
||||||
|
assert.equal(harness.storage.objects.size, 3);
|
||||||
|
|
||||||
|
assert.deepEqual(harness.service.list(), [result.asset]);
|
||||||
|
assert.deepEqual(harness.service.get(result.asset.id), result.asset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exact duplicate detection takes precedence over filename and display name", async (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.close());
|
||||||
|
const contents = Buffer.from("same image bytes");
|
||||||
|
|
||||||
|
const [created] = await harness.service.upload([
|
||||||
|
harness.uploadFile("first.png", contents),
|
||||||
|
]);
|
||||||
|
const [sameFilename] = await harness.service.upload([
|
||||||
|
harness.uploadFile("first.png", contents),
|
||||||
|
]);
|
||||||
|
const [differentFilename] = await harness.service.upload(
|
||||||
|
[harness.uploadFile("completely different name.webp", contents)],
|
||||||
|
{ displayName: "A conflicting requested name is ignored for an exact duplicate" },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const duplicate of [sameFilename, differentFilename]) {
|
||||||
|
assert.equal(duplicate.created, false);
|
||||||
|
assert.equal(duplicate.duplicate, true);
|
||||||
|
assert.equal(duplicate.asset.id, created.asset.id);
|
||||||
|
}
|
||||||
|
assert.equal(harness.service.list().length, 1);
|
||||||
|
assert.equal(harness.storage.calls.filter((call) => call.operation === "put").length, 3);
|
||||||
|
assert.equal(harness.imageProcessor.calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different content with the same proposed name is a conflict, not a duplicate", async (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.close());
|
||||||
|
await harness.service.upload([harness.uploadFile("bulletin.png", "image one")]);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
harness.service.upload([harness.uploadFile("bulletin.jpg", "image two")]),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.statusCode, 409);
|
||||||
|
assert.equal(error.code, "ASSET_NAME_CONFLICT");
|
||||||
|
assert.equal(error.details.asset.displayName, "bulletin");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(harness.service.list().length, 1);
|
||||||
|
assert.equal(harness.imageProcessor.calls.length, 1);
|
||||||
|
|
||||||
|
const [created] = await harness.service.upload(
|
||||||
|
[harness.uploadFile("bulletin.jpg", "image two")],
|
||||||
|
{ displayName: "Second bulletin" },
|
||||||
|
);
|
||||||
|
assert.equal(created.created, true);
|
||||||
|
assert.notEqual(created.asset.sourceSha256, harness.service.list()[1].sourceSha256);
|
||||||
|
assert.equal(harness.service.list().length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple-file upload preserves order and deduplicates repeated bytes in the batch", async (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.close());
|
||||||
|
|
||||||
|
const results = await harness.service.upload([
|
||||||
|
harness.uploadFile("alpha.png", "alpha bytes"),
|
||||||
|
harness.uploadFile("beta.png", "beta bytes"),
|
||||||
|
harness.uploadFile("alpha-copy.png", "alpha bytes"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
results.map(({ created, duplicate }) => ({ created, duplicate })),
|
||||||
|
[
|
||||||
|
{ created: true, duplicate: false },
|
||||||
|
{ created: true, duplicate: false },
|
||||||
|
{ created: false, duplicate: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(results[0].asset.id, results[2].asset.id);
|
||||||
|
assert.equal(harness.service.list().length, 2);
|
||||||
|
assert.equal(harness.imageProcessor.calls.length, 2);
|
||||||
|
assert.equal(harness.storage.objects.size, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all new images are decoded before the batch mutates SQLite or storage", async (t) => {
|
||||||
|
const expected = Object.assign(new Error("bad second image"), {
|
||||||
|
code: "MALFORMED_IMAGE",
|
||||||
|
statusCode: 422,
|
||||||
|
});
|
||||||
|
let processCount = 0;
|
||||||
|
const regularProcessor = createFakeImageProcessor();
|
||||||
|
const harness = createHarness({
|
||||||
|
imageProcessor: {
|
||||||
|
async process(filename) {
|
||||||
|
processCount += 1;
|
||||||
|
if (processCount === 2) throw expected;
|
||||||
|
return regularProcessor.process(filename);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
t.after(() => harness.close());
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
harness.service.upload([
|
||||||
|
harness.uploadFile("valid.png", "valid"),
|
||||||
|
harness.uploadFile("broken.png", "broken"),
|
||||||
|
]),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
assert.equal(harness.service.list().length, 0);
|
||||||
|
assert.equal(harness.storage.objects.size, 0);
|
||||||
|
assert.equal(harness.storage.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent identical uploads converge on one ready asset", async (t) => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
storage.setPutDelay(30);
|
||||||
|
const harness = createHarness({ storage });
|
||||||
|
t.after(() => harness.close());
|
||||||
|
const bytes = Buffer.from("racing identical upload");
|
||||||
|
|
||||||
|
const [[left], [right]] = await Promise.all([
|
||||||
|
harness.service.upload([harness.uploadFile("race.png", bytes)]),
|
||||||
|
harness.service.upload([harness.uploadFile("other-name.png", bytes)]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(harness.service.list().length, 1);
|
||||||
|
assert.equal(left.asset.id, right.asset.id);
|
||||||
|
assert.deepEqual([left.created, right.created].sort(), [false, true]);
|
||||||
|
assert.deepEqual([left.duplicate, right.duplicate].sort(), [false, true]);
|
||||||
|
assert.equal(storage.calls.filter((call) => call.operation === "put").length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a partial storage failure leaves a diagnosable failed row and no ready URLs", async (t) => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
storage.setPutFailure(new StorageUnavailableError(new Error("offline")), 1);
|
||||||
|
const harness = createHarness({ storage });
|
||||||
|
t.after(() => harness.close());
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
harness.service.upload([harness.uploadFile("failure.png", "failure bytes")]),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.statusCode, 503);
|
||||||
|
assert.equal(error.details.retryable, true);
|
||||||
|
assert.match(error.details.assetId, /^[0-9a-f-]{36}$/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const [failed] = harness.service.list();
|
||||||
|
assert.equal(failed.status, "failed");
|
||||||
|
assert.match(failed.processingError, /^STORAGE_UNAVAILABLE:/);
|
||||||
|
assert.equal(failed.display.url, null);
|
||||||
|
assert.equal(failed.thumbnail.url, null);
|
||||||
|
assert.equal(failed.original.url, null);
|
||||||
|
assert.equal(storage.objects.size, 0);
|
||||||
|
const row = rawAsset(harness.database, failed.id);
|
||||||
|
assert.match(
|
||||||
|
row.original_object_key,
|
||||||
|
new RegExp(`^assets/${failed.id}/original/[0-9a-f]{64}$`),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
row.display_object_key,
|
||||||
|
`assets/${failed.id}/display/${row.display_sha256}.webp`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
row.thumbnail_object_key,
|
||||||
|
`assets/${failed.id}/thumbnail/${row.thumbnail_sha256}.webp`,
|
||||||
|
);
|
||||||
|
assert.equal(storage.calls.filter((call) => call.operation === "put").length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
new Set(
|
||||||
|
storage.calls
|
||||||
|
.filter((call) => call.operation === "delete")
|
||||||
|
.map((call) => call.key),
|
||||||
|
),
|
||||||
|
new Set([
|
||||||
|
row.original_object_key,
|
||||||
|
row.display_object_key,
|
||||||
|
row.thumbnail_object_key,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
harness.service.upload([harness.uploadFile("retry.png", "failure bytes")]),
|
||||||
|
(error) => error.code === "ASSET_PROCESSING_FAILED" && error.statusCode === 409,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(await harness.service.remove(failed.id), {
|
||||||
|
id: failed.id,
|
||||||
|
deleted: true,
|
||||||
|
});
|
||||||
|
assert.equal(harness.service.list().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rename and expiration edits change only approved SQLite metadata", async (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.close());
|
||||||
|
const [upload] = await harness.service.upload([
|
||||||
|
harness.uploadFile("editable.png", "editable bytes"),
|
||||||
|
]);
|
||||||
|
const before = rawAsset(harness.database, upload.asset.id);
|
||||||
|
|
||||||
|
const expiring = harness.service.update(upload.asset.id, {
|
||||||
|
display_name: " New display name ",
|
||||||
|
expiration_at: "2035-05-06T07:08:09.000Z",
|
||||||
|
});
|
||||||
|
assert.equal(expiring.displayName, "New display name");
|
||||||
|
assert.equal(expiring.expirationAt, Date.parse("2035-05-06T07:08:09.000Z"));
|
||||||
|
|
||||||
|
const permanent = harness.service.update(upload.asset.id, { expirationAt: null });
|
||||||
|
assert.equal(permanent.expirationAt, null);
|
||||||
|
const after = rawAsset(harness.database, upload.asset.id);
|
||||||
|
assert.equal(after.original_object_key, before.original_object_key);
|
||||||
|
assert.equal(after.display_object_key, before.display_object_key);
|
||||||
|
assert.equal(after.thumbnail_object_key, before.thumbnail_object_key);
|
||||||
|
assert.equal(harness.storage.calls.filter((call) => call.operation === "put").length, 3);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () => harness.service.update(upload.asset.id, { status: "failed" }),
|
||||||
|
(error) => error.code === "INVALID_ASSET_UPDATE",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => harness.service.update(upload.asset.id, { expirationAt: -1 }),
|
||||||
|
(error) => error.code === "INVALID_ASSET_EXPIRATION",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("references describe playlist, normal, and static use and block deletion", async (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.close());
|
||||||
|
const [upload] = await harness.service.upload([
|
||||||
|
harness.uploadFile("referenced.png", "referenced bytes"),
|
||||||
|
]);
|
||||||
|
const id = upload.asset.id;
|
||||||
|
const now = 2_100_000_000_000;
|
||||||
|
harness.database
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.run("playlist-1", "Sunday", now, now);
|
||||||
|
harness.database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO playlist_entries
|
||||||
|
(id, playlist_id, asset_id, enabled, position, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 1, 0, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run("entry-1", "playlist-1", id, now, now);
|
||||||
|
harness.database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE playback_state
|
||||||
|
SET active_playlist_id = ?, current_entry_id = ?, static_asset_id = ?, updated_at = ?
|
||||||
|
WHERE output_id = 'global'`,
|
||||||
|
)
|
||||||
|
.run("playlist-1", "entry-1", id, now);
|
||||||
|
|
||||||
|
const references = harness.service.references(id);
|
||||||
|
assert.equal(references.canDelete, false);
|
||||||
|
assert.deepEqual(references.blockerCodes, [
|
||||||
|
"PLAYLIST_REFERENCES",
|
||||||
|
"CURRENT_PLAYBACK_TARGET",
|
||||||
|
"CURRENT_STATIC_TARGET",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(references.playlists[0], {
|
||||||
|
playlistId: "playlist-1",
|
||||||
|
playlistName: "Sunday",
|
||||||
|
playlistEntryId: "entry-1",
|
||||||
|
enabled: true,
|
||||||
|
position: 0,
|
||||||
|
isActivePlaylist: true,
|
||||||
|
isCurrentNormalTarget: true,
|
||||||
|
});
|
||||||
|
assert.equal(references.playback[0].isCurrentNormalTarget, true);
|
||||||
|
assert.equal(references.playback[0].isCurrentStaticTarget, true);
|
||||||
|
|
||||||
|
const callsBeforeDelete = harness.storage.calls.length;
|
||||||
|
await assert.rejects(
|
||||||
|
harness.service.remove(id),
|
||||||
|
(error) => {
|
||||||
|
assert.equal(error.code, "ASSET_REFERENCED");
|
||||||
|
assert.equal(error.statusCode, 409);
|
||||||
|
assert.deepEqual(error.details.blockerCodes, [
|
||||||
|
"PLAYLIST_REFERENCES",
|
||||||
|
"CURRENT_PLAYBACK_TARGET",
|
||||||
|
"CURRENT_STATIC_TARGET",
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(harness.storage.calls.length, callsBeforeDelete);
|
||||||
|
assert.equal(rawAsset(harness.database, id).status, "ready");
|
||||||
|
|
||||||
|
harness.database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE playback_state
|
||||||
|
SET active_playlist_id = NULL, current_entry_id = NULL, static_asset_id = NULL
|
||||||
|
WHERE output_id = 'global'`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
harness.database.prepare("DELETE FROM playlist_entries WHERE id = ?").run("entry-1");
|
||||||
|
assert.equal(harness.service.references(id).canDelete, true);
|
||||||
|
await harness.service.remove(id);
|
||||||
|
assert.equal(harness.storage.objects.size, 0);
|
||||||
|
assert.equal(rawAsset(harness.database, id), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed storage deletion retains a retryable deleting row", async (t) => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
const harness = createHarness({ storage });
|
||||||
|
t.after(() => harness.close());
|
||||||
|
const [upload] = await harness.service.upload([
|
||||||
|
harness.uploadFile("delete-me.png", "delete me"),
|
||||||
|
]);
|
||||||
|
storage.setDeleteFailure(new StorageUnavailableError(new Error("offline")));
|
||||||
|
|
||||||
|
await assert.rejects(harness.service.remove(upload.asset.id), (error) => {
|
||||||
|
assert.equal(error.statusCode, 503);
|
||||||
|
assert.equal(error.details.assetId, upload.asset.id);
|
||||||
|
assert.equal(error.details.retryable, true);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assert.equal(rawAsset(harness.database, upload.asset.id).status, "deleting");
|
||||||
|
assert.throws(
|
||||||
|
() => harness.service.update(upload.asset.id, { displayName: "Cannot edit" }),
|
||||||
|
(error) => error.code === "ASSET_DELETING",
|
||||||
|
);
|
||||||
|
|
||||||
|
await harness.service.remove(upload.asset.id);
|
||||||
|
assert.equal(rawAsset(harness.database, upload.asset.id), undefined);
|
||||||
|
assert.equal(storage.objects.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("processing assets cannot be deleted and storage is not touched", async (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.close());
|
||||||
|
const timestamp = Date.now();
|
||||||
|
harness.database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO assets
|
||||||
|
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'processing', ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
"00000000-0000-4000-8000-999999999999",
|
||||||
|
"f".repeat(64),
|
||||||
|
"Still processing",
|
||||||
|
"processing.png",
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
harness.service.references("00000000-0000-4000-8000-999999999999")
|
||||||
|
.canDelete,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
harness.service.remove("00000000-0000-4000-8000-999999999999"),
|
||||||
|
(error) => error.code === "ASSET_PROCESSING" && error.statusCode === 409,
|
||||||
|
);
|
||||||
|
assert.equal(harness.storage.calls.length, 0);
|
||||||
|
assert.equal(
|
||||||
|
rawAsset(harness.database, "00000000-0000-4000-8000-999999999999").status,
|
||||||
|
"processing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("media descriptors only allow the recorded version and map missing bytes to 503", async (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.close());
|
||||||
|
const [upload] = await harness.service.upload([
|
||||||
|
harness.uploadFile("media.png", "media bytes"),
|
||||||
|
]);
|
||||||
|
const { asset } = upload;
|
||||||
|
|
||||||
|
const display = harness.service.mediaDescriptor(
|
||||||
|
asset.id,
|
||||||
|
"display",
|
||||||
|
asset.display.sha256,
|
||||||
|
);
|
||||||
|
assert.equal(display.contentType, "image/webp");
|
||||||
|
assert.equal(display.contentLength, asset.display.byteSize);
|
||||||
|
const stored = await harness.service.readStoredObject(display);
|
||||||
|
assert.deepEqual(await bufferBody(stored.body), harness.storage.objects.get(display.key).body);
|
||||||
|
|
||||||
|
for (const requestedHash of ["a".repeat(64), "../original/key", "not-a-hash"]) {
|
||||||
|
assert.throws(
|
||||||
|
() => harness.service.mediaDescriptor(asset.id, "display", requestedHash),
|
||||||
|
(error) => error.code === "MEDIA_NOT_FOUND" && error.statusCode === 404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
harness.storage.objects.delete(display.key);
|
||||||
|
await assert.rejects(harness.service.readStoredObject(display), (error) => {
|
||||||
|
assert.equal(error.code, "STORAGE_INCONSISTENCY");
|
||||||
|
assert.equal(error.statusCode, 503);
|
||||||
|
assert.deepEqual(error.details, { assetId: asset.id, retryable: true });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { ConfigError, loadConfig, redactedConfig } = require("../src/config");
|
||||||
|
|
||||||
|
function validEnvironment(overrides = {}) {
|
||||||
|
return {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
HOST: "127.0.0.1",
|
||||||
|
PORT: "7532",
|
||||||
|
DATABASE_PATH: "/data/db/announcements.sqlite",
|
||||||
|
APP_TIMEZONE: "America/Chicago",
|
||||||
|
S3_ENDPOINT: "http://seaweedfs:8333",
|
||||||
|
S3_REGION: "us-east-1",
|
||||||
|
S3_BUCKET: "announcements",
|
||||||
|
S3_ACCESS_KEY_ID: "test-access-key",
|
||||||
|
S3_SECRET_ACCESS_KEY: "test-secret-key",
|
||||||
|
S3_FORCE_PATH_STYLE: "true",
|
||||||
|
MQTT_URL: "mqtt://broker:1883",
|
||||||
|
MQTT_USERNAME: "mqtt-user",
|
||||||
|
MQTT_PASSWORD: "mqtt-password",
|
||||||
|
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/example",
|
||||||
|
HA_WEBHOOK_SECRET: "webhook-secret",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loadConfig validates and normalizes the supported environment", () => {
|
||||||
|
const config = loadConfig(validEnvironment());
|
||||||
|
|
||||||
|
assert.equal(config.environment, "production");
|
||||||
|
assert.equal(config.port, 7532);
|
||||||
|
assert.equal(config.timeZone, "America/Chicago");
|
||||||
|
assert.equal(config.storage.endpoint, "http://seaweedfs:8333");
|
||||||
|
assert.equal(config.storage.forcePathStyle, true);
|
||||||
|
assert.equal(config.storage.healthProbeIntervalMs, 30_000);
|
||||||
|
assert.equal(config.storage.connectionTimeoutMs, 2_000);
|
||||||
|
assert.equal(config.storage.requestTimeoutMs, 5_000);
|
||||||
|
assert.equal(config.storage.maxAttempts, 2);
|
||||||
|
assert.equal(config.processing.tempDirectory, "/tmp/announcements-uploads");
|
||||||
|
assert.equal(config.processing.maxFileBytes, 25 * 1024 * 1024);
|
||||||
|
assert.equal(config.processing.maxFiles, 20);
|
||||||
|
assert.equal(config.processing.maxInputPixels, 40_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("production requires private S3 credentials", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ S3_ACCESS_KEY_ID: "" })),
|
||||||
|
(error) =>
|
||||||
|
error instanceof ConfigError && error.field === "S3_ACCESS_KEY_ID",
|
||||||
|
);
|
||||||
|
|
||||||
|
const development = loadConfig(
|
||||||
|
validEnvironment({
|
||||||
|
NODE_ENV: "development",
|
||||||
|
S3_ACCESS_KEY_ID: "",
|
||||||
|
S3_SECRET_ACCESS_KEY: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(development.storage.accessKeyId, null);
|
||||||
|
assert.equal(development.storage.secretAccessKey, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadConfig rejects invalid typed values", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ PORT: "7532.5" })),
|
||||||
|
/PORT must be an integer/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ APP_TIMEZONE: "Church/Local" })),
|
||||||
|
/valid IANA time zone/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ S3_FORCE_PATH_STYLE: "sometimes" })),
|
||||||
|
/must be a boolean/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ S3_HEALTH_PROBE_INTERVAL_MS: "999" })),
|
||||||
|
/S3_HEALTH_PROBE_INTERVAL_MS must be at least 1000/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ S3_REQUEST_TIMEOUT_MS: "999" })),
|
||||||
|
/S3_REQUEST_TIMEOUT_MS must be at least 1000/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ S3_CONNECT_TIMEOUT_MS: "249" })),
|
||||||
|
/S3_CONNECT_TIMEOUT_MS must be at least 250/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ S3_MAX_ATTEMPTS: "6" })),
|
||||||
|
/S3_MAX_ATTEMPTS must be at most 5/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ DATABASE_PATH: "relative.sqlite" })),
|
||||||
|
/must be an absolute path/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ UPLOAD_TEMP_DIRECTORY: "relative" })),
|
||||||
|
/UPLOAD_TEMP_DIRECTORY must be an absolute path/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ UPLOAD_MAX_FILE_BYTES: "0" })),
|
||||||
|
/UPLOAD_MAX_FILE_BYTES must be at least 1/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ UPLOAD_MAX_FILES: "101" })),
|
||||||
|
/UPLOAD_MAX_FILES must be at most 100/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig(validEnvironment({ IMAGE_MAX_INPUT_PIXELS: "no-limit" })),
|
||||||
|
/IMAGE_MAX_INPUT_PIXELS must be an integer/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redactedConfig never returns credentials, passwords, paths, or webhook URLs", () => {
|
||||||
|
const summary = redactedConfig(loadConfig(validEnvironment()));
|
||||||
|
const serialized = JSON.stringify(summary);
|
||||||
|
|
||||||
|
assert.equal(summary.storage.configured, true);
|
||||||
|
assert.equal(summary.mqtt.configured, true);
|
||||||
|
assert.equal(summary.notifications.configured, true);
|
||||||
|
assert.doesNotMatch(serialized, /test-access-key/);
|
||||||
|
assert.doesNotMatch(serialized, /test-secret-key/);
|
||||||
|
assert.doesNotMatch(serialized, /mqtt-password/);
|
||||||
|
assert.doesNotMatch(serialized, /webhook-secret/);
|
||||||
|
assert.doesNotMatch(serialized, /announcements\.sqlite/);
|
||||||
|
assert.doesNotMatch(serialized, /seaweedfs/);
|
||||||
|
assert.doesNotMatch(serialized, /home-assistant/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { openDatabase } = require("../src/database");
|
||||||
|
const { migrationStatus, runMigrations } = require("../src/database/migrate");
|
||||||
|
|
||||||
|
function migratedDatabase() {
|
||||||
|
const database = openDatabase({ path: ":memory:" });
|
||||||
|
const migrations = runMigrations(database);
|
||||||
|
return { database, migrations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnNames(database, table) {
|
||||||
|
return database.pragma(`table_info(${table})`).map((column) => column.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("initial migration creates every approved Phase 0 table and seed row", (t) => {
|
||||||
|
const { database, migrations } = migratedDatabase();
|
||||||
|
t.after(() => database.close());
|
||||||
|
|
||||||
|
const tables = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT name FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY name`,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
.map((row) => row.name);
|
||||||
|
|
||||||
|
assert.deepEqual(tables, [
|
||||||
|
"app_settings",
|
||||||
|
"asset_import_sources",
|
||||||
|
"assets",
|
||||||
|
"display_clients",
|
||||||
|
"notification_incidents",
|
||||||
|
"notification_outbox",
|
||||||
|
"playback_state",
|
||||||
|
"playlist_entries",
|
||||||
|
"playlists",
|
||||||
|
"schema_migrations",
|
||||||
|
]);
|
||||||
|
assert.equal(migrations.currentVersion, 1);
|
||||||
|
assert.equal(migrations.latestAvailableVersion, 1);
|
||||||
|
assert.deepEqual(migrations.pendingVersions, []);
|
||||||
|
|
||||||
|
assert.deepEqual(database.prepare("SELECT * FROM app_settings").get(), {
|
||||||
|
id: 1,
|
||||||
|
revision: 1,
|
||||||
|
default_duration_ms: 7000,
|
||||||
|
default_background_mode: "blurred",
|
||||||
|
default_solid_background_color: "#000000",
|
||||||
|
default_background_darkened: 0,
|
||||||
|
default_blur_px: 20,
|
||||||
|
crossfade_duration_ms: 1000,
|
||||||
|
updated_at: database.prepare("SELECT updated_at FROM app_settings").get()
|
||||||
|
.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
const playback = database.prepare("SELECT * FROM playback_state").get();
|
||||||
|
assert.equal(playback.output_id, "global");
|
||||||
|
assert.equal(playback.mode, "on");
|
||||||
|
assert.equal(playback.pending_playlist_id, null);
|
||||||
|
assert.equal(playback.pending_playlist_requested_at, null);
|
||||||
|
assert.ok(columnNames(database, "playlist_entries").includes(
|
||||||
|
"background_darkened_override",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migrations are idempotent and report their status", (t) => {
|
||||||
|
const { database } = migratedDatabase();
|
||||||
|
t.after(() => database.close());
|
||||||
|
|
||||||
|
const rerun = runMigrations(database);
|
||||||
|
assert.equal(rerun.applied.length, 1);
|
||||||
|
assert.deepEqual(rerun.pendingVersions, []);
|
||||||
|
assert.deepEqual(migrationStatus(database), rerun);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed migration rolls back its schema and version record", (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-migrations-"));
|
||||||
|
const migrationPath = path.join(directory, "001_test.sql");
|
||||||
|
fs.writeFileSync(
|
||||||
|
migrationPath,
|
||||||
|
"CREATE TABLE should_roll_back (id INTEGER); THIS IS NOT SQL;",
|
||||||
|
);
|
||||||
|
const database = openDatabase({ path: ":memory:" });
|
||||||
|
t.after(() => {
|
||||||
|
database.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(() => runMigrations(database, directory));
|
||||||
|
assert.equal(
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
"SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'should_roll_back'",
|
||||||
|
)
|
||||||
|
.get().count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
database.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get().count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(migrationPath, "CREATE TABLE recovered (id INTEGER);");
|
||||||
|
const status = runMigrations(database, directory);
|
||||||
|
assert.equal(status.currentVersion, 1);
|
||||||
|
assert.equal(
|
||||||
|
database
|
||||||
|
.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'recovered'")
|
||||||
|
.get().count,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a library asset can occur only once in a given playlist", (t) => {
|
||||||
|
const { database } = migratedDatabase();
|
||||||
|
t.after(() => database.close());
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO assets
|
||||||
|
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
|
||||||
|
)
|
||||||
|
.run("asset-1", "a".repeat(64), "Welcome", "welcome.png", now, now);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO playlists (id, name, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run("playlist-1", "Sunday", now, now);
|
||||||
|
|
||||||
|
const insert = database.prepare(
|
||||||
|
`INSERT INTO playlist_entries
|
||||||
|
(id, playlist_id, asset_id, position, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
insert.run("entry-1", "playlist-1", "asset-1", 0, now, now);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => insert.run("entry-2", "playlist-1", "asset-1", 1, now, now),
|
||||||
|
/UNIQUE constraint failed/,
|
||||||
|
);
|
||||||
|
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO playlists (id, name, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run("playlist-2", "Midweek", now, now);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
insert.run("entry-3", "playlist-2", "asset-1", 0, now, now),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => database.prepare("DELETE FROM assets WHERE id = 'asset-1'").run(),
|
||||||
|
/FOREIGN KEY constraint failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("darkening overrides accept inherited, off, and on only", (t) => {
|
||||||
|
const { database } = migratedDatabase();
|
||||||
|
t.after(() => database.close());
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO playlists (id, name, created_at, updated_at)
|
||||||
|
VALUES ('playlist-1', 'Sunday', ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(now, now);
|
||||||
|
const insertAsset = database.prepare(
|
||||||
|
`INSERT INTO assets
|
||||||
|
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
|
||||||
|
);
|
||||||
|
const insertEntry = database.prepare(
|
||||||
|
`INSERT INTO playlist_entries
|
||||||
|
(id, playlist_id, asset_id, background_darkened_override, position, created_at, updated_at)
|
||||||
|
VALUES (?, 'playlist-1', ?, ?, ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [index, override] of [null, 0, 1].entries()) {
|
||||||
|
const assetId = `asset-${index}`;
|
||||||
|
insertAsset.run(
|
||||||
|
assetId,
|
||||||
|
String(index).repeat(64),
|
||||||
|
`Asset ${index}`,
|
||||||
|
`asset-${index}.png`,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
insertEntry.run(
|
||||||
|
`entry-${index}`,
|
||||||
|
assetId,
|
||||||
|
override,
|
||||||
|
index,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
insertAsset.run(
|
||||||
|
"asset-invalid",
|
||||||
|
"f".repeat(64),
|
||||||
|
"Invalid override",
|
||||||
|
"invalid.png",
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => insertEntry.run("entry-invalid", "asset-invalid", 2, 3, now, now),
|
||||||
|
/CHECK constraint failed/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
database
|
||||||
|
.prepare("UPDATE app_settings SET default_background_darkened = 2 WHERE id = 1")
|
||||||
|
.run(),
|
||||||
|
/CHECK constraint failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending playlist activation requires a playlist and request time together", (t) => {
|
||||||
|
const { database } = migratedDatabase();
|
||||||
|
t.after(() => database.close());
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO playlists (id, name, created_at, updated_at)
|
||||||
|
VALUES ('playlist-1', 'Sunday', ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(now, now);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
"UPDATE playback_state SET pending_playlist_id = 'playlist-1' WHERE output_id = 'global'",
|
||||||
|
)
|
||||||
|
.run(),
|
||||||
|
/CHECK constraint failed/,
|
||||||
|
);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE playback_state
|
||||||
|
SET pending_playlist_id = 'playlist-1', pending_playlist_requested_at = ?
|
||||||
|
WHERE output_id = 'global'`,
|
||||||
|
)
|
||||||
|
.run(now),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => database.prepare("DELETE FROM playlists WHERE id = 'playlist-1'").run(),
|
||||||
|
/FOREIGN KEY constraint failed/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE playback_state
|
||||||
|
SET pending_playlist_id = 'missing', pending_playlist_requested_at = ?
|
||||||
|
WHERE output_id = 'global'`,
|
||||||
|
)
|
||||||
|
.run(now),
|
||||||
|
/FOREIGN KEY constraint failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { openDatabase } = require("../src/database");
|
||||||
|
const { runMigrations } = require("../src/database/migrate");
|
||||||
|
const {
|
||||||
|
DISPLAY_ERROR_SEVERITY,
|
||||||
|
createDisplayService,
|
||||||
|
errorConditionKey,
|
||||||
|
parseDisplayError,
|
||||||
|
} = require("../src/displays/service");
|
||||||
|
const { createNotificationService } = require("../src/notifications");
|
||||||
|
|
||||||
|
const BASE_TIME = 2_000_000_000_000;
|
||||||
|
const THRESHOLD = 45_000;
|
||||||
|
|
||||||
|
function createClock(initial = BASE_TIME) {
|
||||||
|
let current = initial;
|
||||||
|
let sequence = 0;
|
||||||
|
const timers = new Set();
|
||||||
|
return {
|
||||||
|
now: () => current,
|
||||||
|
set(value) {
|
||||||
|
current = value;
|
||||||
|
},
|
||||||
|
setTimeout(callback, delay) {
|
||||||
|
const timer = { at: current + delay, callback, cleared: false, id: ++sequence };
|
||||||
|
timers.add(timer);
|
||||||
|
return timer;
|
||||||
|
},
|
||||||
|
clearTimeout(timer) {
|
||||||
|
if (timer) timer.cleared = true;
|
||||||
|
},
|
||||||
|
runTo(value) {
|
||||||
|
current = value;
|
||||||
|
for (let guard = 0; guard < 200; guard += 1) {
|
||||||
|
const timer = [...timers]
|
||||||
|
.filter((candidate) => !candidate.cleared && !candidate.fired && candidate.at <= current)
|
||||||
|
.sort((left, right) => left.at - right.at || left.id - right.id)[0];
|
||||||
|
if (!timer) return;
|
||||||
|
timer.fired = true;
|
||||||
|
timer.callback();
|
||||||
|
}
|
||||||
|
throw new Error("timer loop did not settle");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function heartbeatPayload(overrides = {}) {
|
||||||
|
return {
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientId: "lobby-display",
|
||||||
|
outputId: "global",
|
||||||
|
activePlaylistId: "playlist-a",
|
||||||
|
playlistRevision: 12,
|
||||||
|
playbackRevision: 42,
|
||||||
|
currentEntryId: "entry-a",
|
||||||
|
currentAssetId: "asset-a",
|
||||||
|
cachedAssetCount: 10,
|
||||||
|
playableAssetCount: 10,
|
||||||
|
mostRecentError: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness({ start = true } = {}) {
|
||||||
|
const database = openDatabase({ path: ":memory:" });
|
||||||
|
runMigrations(database);
|
||||||
|
const clock = createClock();
|
||||||
|
const events = [];
|
||||||
|
let idCounter = 0;
|
||||||
|
const notifications = createNotificationService({
|
||||||
|
database,
|
||||||
|
webhookUrl: "http://127.0.0.1:9/hook",
|
||||||
|
now: clock.now,
|
||||||
|
setTimeoutFn: clock.setTimeout,
|
||||||
|
clearTimeoutFn: clock.clearTimeout,
|
||||||
|
createId: () => `note-${++idCounter}`,
|
||||||
|
fetchFn: () => Promise.resolve({ status: 200 }),
|
||||||
|
logger: { warn() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
const service = createDisplayService({
|
||||||
|
database,
|
||||||
|
notifications,
|
||||||
|
now: clock.now,
|
||||||
|
setTimeoutFn: clock.setTimeout,
|
||||||
|
clearTimeoutFn: clock.clearTimeout,
|
||||||
|
offlineThresholdMs: THRESHOLD,
|
||||||
|
onStatusChanged: (event) => events.push(event),
|
||||||
|
logger: { warn() {} },
|
||||||
|
});
|
||||||
|
if (start) service.start();
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
build,
|
||||||
|
clock,
|
||||||
|
database,
|
||||||
|
events,
|
||||||
|
notifications,
|
||||||
|
service: build(),
|
||||||
|
outbox: () =>
|
||||||
|
database.prepare("SELECT * FROM notification_outbox ORDER BY created_at, id").all(),
|
||||||
|
incidents: () =>
|
||||||
|
database.prepare("SELECT * FROM notification_incidents ORDER BY opened_at").all(),
|
||||||
|
rows: () => database.prepare("SELECT * FROM display_clients").all(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a first heartbeat creates the display and later heartbeats update the same record", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
const first = harness.service.heartbeat(heartbeatPayload());
|
||||||
|
assert.equal(first.status, "online");
|
||||||
|
assert.equal(first.clientId, "lobby-display");
|
||||||
|
assert.equal(harness.rows().length, 1);
|
||||||
|
assert.equal(harness.events.at(-1).reason, "first_seen");
|
||||||
|
|
||||||
|
harness.clock.set(BASE_TIME + 12_000);
|
||||||
|
const second = harness.service.heartbeat(
|
||||||
|
heartbeatPayload({ playbackRevision: 43, cachedAssetCount: 11 }),
|
||||||
|
);
|
||||||
|
assert.equal(harness.rows().length, 1, "the persistent client ID is the display identity");
|
||||||
|
assert.equal(second.playbackRevision, 43);
|
||||||
|
assert.equal(second.cachedAssetCount, 11);
|
||||||
|
assert.equal(
|
||||||
|
new Date(second.firstSeenAt).getTime(),
|
||||||
|
BASE_TIME,
|
||||||
|
"first_seen_at is preserved across heartbeats",
|
||||||
|
);
|
||||||
|
assert.equal(new Date(second.lastSeenAt).getTime(), BASE_TIME + 12_000);
|
||||||
|
assert.equal(
|
||||||
|
harness.events.filter((event) => event.reason === "first_seen").length,
|
||||||
|
1,
|
||||||
|
"a steady heartbeat creates no realtime churn",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the same display keeps its identity across a new Socket.IO connection", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
// A browser reconnect produces a new socket but the same persisted client ID.
|
||||||
|
harness.clock.set(BASE_TIME + 20_000);
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
|
||||||
|
assert.equal(harness.rows().length, 1);
|
||||||
|
assert.equal(harness.service.list().length, 1);
|
||||||
|
assert.equal(harness.service.list()[0].status, "online");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed telemetry is rejected safely and never creates a display", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
const invalid = [
|
||||||
|
null,
|
||||||
|
"string",
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
heartbeatPayload({ protocolVersion: 2 }),
|
||||||
|
heartbeatPayload({ protocolVersion: undefined }),
|
||||||
|
heartbeatPayload({ clientId: "" }),
|
||||||
|
heartbeatPayload({ clientId: " " }),
|
||||||
|
heartbeatPayload({ clientId: 42 }),
|
||||||
|
heartbeatPayload({ clientId: "x".repeat(300) }),
|
||||||
|
heartbeatPayload({ cachedAssetCount: -1 }),
|
||||||
|
heartbeatPayload({ cachedAssetCount: 1.5 }),
|
||||||
|
heartbeatPayload({ playbackRevision: -5 }),
|
||||||
|
heartbeatPayload({ mostRecentError: { nested: true } }),
|
||||||
|
];
|
||||||
|
for (const payload of invalid) {
|
||||||
|
assert.throws(() => harness.service.heartbeat(payload), TypeError);
|
||||||
|
}
|
||||||
|
assert.equal(harness.rows().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an oversized error string is bounded before it is stored", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
const result = harness.service.heartbeat(
|
||||||
|
heartbeatPayload({ mostRecentError: "E".repeat(5_000_000) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.mostRecentError.length <= 500, "a client cannot store megabytes");
|
||||||
|
assert.ok(harness.rows()[0].last_error.length <= 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heartbeats never change playback or playlist revisions", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
const playbackBefore = harness.database
|
||||||
|
.prepare("SELECT revision, updated_at FROM playback_state WHERE output_id = 'global'")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
for (let beat = 0; beat < 5; beat += 1) {
|
||||||
|
harness.clock.set(BASE_TIME + beat * 10_000);
|
||||||
|
harness.service.heartbeat(heartbeatPayload({ playbackRevision: 99 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
harness.database
|
||||||
|
.prepare("SELECT revision, updated_at FROM playback_state WHERE output_id = 'global'")
|
||||||
|
.get(),
|
||||||
|
playbackBefore,
|
||||||
|
"telemetry is read-only reporting",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one missed heartbeat does not mark a display offline before the threshold", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
|
||||||
|
// Two heartbeat intervals have passed but the 45s threshold has not.
|
||||||
|
harness.clock.runTo(BASE_TIME + 30_000);
|
||||||
|
assert.equal(harness.service.list()[0].status, "online");
|
||||||
|
assert.equal(harness.incidents().length, 0);
|
||||||
|
|
||||||
|
harness.clock.runTo(BASE_TIME + THRESHOLD - 1);
|
||||||
|
assert.equal(harness.service.list()[0].status, "online");
|
||||||
|
assert.equal(harness.incidents().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("crossing the threshold opens exactly one offline incident that never repeats", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
|
||||||
|
harness.clock.runTo(BASE_TIME + THRESHOLD);
|
||||||
|
assert.equal(harness.service.list()[0].status, "offline");
|
||||||
|
const incidents = harness.incidents();
|
||||||
|
assert.equal(incidents.length, 1);
|
||||||
|
assert.equal(incidents[0].event, "display.offline");
|
||||||
|
assert.equal(incidents[0].condition_key, "display:lobby-display");
|
||||||
|
assert.equal(JSON.parse(incidents[0].context_json).clientId, "lobby-display");
|
||||||
|
assert.equal(harness.outbox().length, 1);
|
||||||
|
assert.equal(harness.events.at(-1).reason, "offline");
|
||||||
|
|
||||||
|
// Staying offline for a long time must not produce more notifications.
|
||||||
|
harness.clock.runTo(BASE_TIME + 30 * 60_000);
|
||||||
|
assert.equal(harness.incidents().length, 1);
|
||||||
|
assert.equal(harness.outbox().length, 1);
|
||||||
|
assert.equal(
|
||||||
|
harness.events.filter((event) => event.reason === "offline").length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a returning heartbeat recovers the display exactly once", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
harness.clock.runTo(BASE_TIME + THRESHOLD);
|
||||||
|
assert.equal(harness.outbox().length, 1);
|
||||||
|
|
||||||
|
harness.clock.set(BASE_TIME + 60_000);
|
||||||
|
const recovered = harness.service.heartbeat(heartbeatPayload());
|
||||||
|
assert.equal(recovered.status, "online");
|
||||||
|
assert.equal(harness.incidents()[0].state, "recovered");
|
||||||
|
assert.equal(harness.outbox().length, 2);
|
||||||
|
assert.equal(harness.outbox()[1].transition, "recovery");
|
||||||
|
assert.equal(harness.events.at(-1).reason, "recovered");
|
||||||
|
|
||||||
|
// Continued healthy heartbeats add nothing further.
|
||||||
|
harness.clock.set(BASE_TIME + 70_000);
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
assert.equal(harness.outbox().length, 2);
|
||||||
|
assert.equal(
|
||||||
|
harness.events.filter((event) => event.reason === "recovered").length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A later disappearance is a new episode with its own single notification.
|
||||||
|
harness.clock.runTo(BASE_TIME + 70_000 + THRESHOLD);
|
||||||
|
assert.equal(harness.incidents().length, 2);
|
||||||
|
assert.equal(harness.outbox().length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a restart preserves known displays and cannot manufacture offline alerts", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
harness.service.stop();
|
||||||
|
|
||||||
|
// The API was down for well over the threshold. A healthy display simply
|
||||||
|
// has not had the chance to report yet, so startup stays silent until a
|
||||||
|
// full threshold has elapsed since the restart.
|
||||||
|
harness.clock.set(BASE_TIME + 10 * 60_000);
|
||||||
|
const restarted = harness.build();
|
||||||
|
assert.equal(harness.rows().length, 1, "display history survives a restart");
|
||||||
|
assert.equal(restarted.list()[0].status, "offline", "status still reflects staleness");
|
||||||
|
assert.equal(harness.incidents().length, 0, "no notification storm after a restart");
|
||||||
|
|
||||||
|
harness.clock.runTo(BASE_TIME + 10 * 60_000 + 20_000);
|
||||||
|
assert.equal(harness.incidents().length, 0, "still inside the startup grace period");
|
||||||
|
|
||||||
|
restarted.heartbeat(heartbeatPayload());
|
||||||
|
assert.equal(restarted.list()[0].status, "online");
|
||||||
|
assert.equal(harness.incidents().length, 0, "a display that comes back was never offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a display still missing after the startup grace period is reported once", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
harness.service.stop();
|
||||||
|
|
||||||
|
harness.clock.set(BASE_TIME + 10 * 60_000);
|
||||||
|
harness.build();
|
||||||
|
harness.clock.runTo(BASE_TIME + 10 * 60_000 + THRESHOLD);
|
||||||
|
|
||||||
|
assert.equal(harness.incidents().length, 1);
|
||||||
|
assert.equal(harness.incidents()[0].event, "display.offline");
|
||||||
|
assert.equal(harness.outbox().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an already-open offline incident is not duplicated by a restart", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
harness.clock.runTo(BASE_TIME + THRESHOLD);
|
||||||
|
assert.equal(harness.incidents().length, 1);
|
||||||
|
harness.service.stop();
|
||||||
|
|
||||||
|
harness.clock.set(BASE_TIME + 20 * 60_000);
|
||||||
|
harness.build();
|
||||||
|
harness.clock.runTo(BASE_TIME + 20 * 60_000 + THRESHOLD + 1_000);
|
||||||
|
|
||||||
|
assert.equal(harness.incidents().length, 1, "the same episode stays open");
|
||||||
|
assert.equal(harness.outbox().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a changed error is surfaced as a meaningful status update", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
harness.events.length = 0;
|
||||||
|
|
||||||
|
harness.clock.set(BASE_TIME + 10_000);
|
||||||
|
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "decode failed for asset-a" }));
|
||||||
|
assert.equal(harness.events.length, 1);
|
||||||
|
assert.equal(harness.events[0].reason, "error_changed");
|
||||||
|
assert.equal(harness.events[0].mostRecentError, "decode failed for asset-a");
|
||||||
|
|
||||||
|
// The same error repeating is not a new event.
|
||||||
|
harness.clock.set(BASE_TIME + 20_000);
|
||||||
|
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "decode failed for asset-a" }));
|
||||||
|
assert.equal(harness.events.length, 1);
|
||||||
|
|
||||||
|
// An arbitrary browser string is telemetry only, never a notification.
|
||||||
|
assert.equal(harness.incidents().length, 0);
|
||||||
|
assert.equal(harness.outbox().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("display status derives from heartbeat freshness for every known client", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
harness.service.heartbeat(heartbeatPayload({ clientId: "lobby" }));
|
||||||
|
harness.clock.set(BASE_TIME + 40_000);
|
||||||
|
harness.service.heartbeat(heartbeatPayload({ clientId: "narthex" }));
|
||||||
|
harness.clock.set(BASE_TIME + 50_000);
|
||||||
|
|
||||||
|
const displays = Object.fromEntries(
|
||||||
|
harness.service.list().map((display) => [display.clientId, display]),
|
||||||
|
);
|
||||||
|
assert.equal(displays.lobby.status, "offline");
|
||||||
|
assert.equal(displays.narthex.status, "online");
|
||||||
|
assert.equal(harness.service.status().online, 1);
|
||||||
|
assert.equal(harness.service.status().offline, 1);
|
||||||
|
assert.equal(harness.service.status().known, 2);
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(harness.service.list());
|
||||||
|
assert.doesNotMatch(serialized, /socket|sid|transport/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a structured display error round-trips without loosening validation", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
// The real display serializes its structured error to bounded JSON, which the
|
||||||
|
// existing string validation already accepts.
|
||||||
|
const structured = JSON.stringify({
|
||||||
|
kind: "slide_decode_failed",
|
||||||
|
assetId: "asset-a",
|
||||||
|
entryId: "entry-a",
|
||||||
|
message: "Could not decode /media/assets/asset-a/display/abc.webp",
|
||||||
|
at: BASE_TIME,
|
||||||
|
});
|
||||||
|
const result = harness.service.heartbeat(
|
||||||
|
heartbeatPayload({ mostRecentError: structured }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.mostRecentError.kind, "slide_decode_failed");
|
||||||
|
assert.equal(result.mostRecentError.assetId, "asset-a");
|
||||||
|
assert.equal(result.mostRecentError.entryId, "entry-a");
|
||||||
|
assert.equal(
|
||||||
|
harness.rows()[0].last_error,
|
||||||
|
structured,
|
||||||
|
"the column still stores bounded text; no migration is required",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-JSON telemetry stays a plain string.
|
||||||
|
harness.clock.set(BASE_TIME + 5_000);
|
||||||
|
assert.equal(
|
||||||
|
harness.service.heartbeat(heartbeatPayload({ mostRecentError: "plain failure" }))
|
||||||
|
.mostRecentError,
|
||||||
|
"plain failure",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Input validation is unchanged: objects are still rejected outright.
|
||||||
|
assert.throws(
|
||||||
|
() => harness.service.heartbeat(heartbeatPayload({ mostRecentError: { kind: "x" } })),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(parseDisplayError(null), null);
|
||||||
|
assert.equal(parseDisplayError("{not json"), "{not json");
|
||||||
|
assert.equal(parseDisplayError('{"no":"kind"}'), '{"no":"kind"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
function structuredError(kind, overrides = {}) {
|
||||||
|
return JSON.stringify({
|
||||||
|
kind,
|
||||||
|
assetId: null,
|
||||||
|
entryId: null,
|
||||||
|
message: `${kind} detail`,
|
||||||
|
at: BASE_TIME,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a structured display error opens exactly one incident and never repeats", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
for (let beat = 0; beat < 6; beat += 1) {
|
||||||
|
harness.clock.set(BASE_TIME + beat * 12_000);
|
||||||
|
harness.service.heartbeat(
|
||||||
|
heartbeatPayload({
|
||||||
|
mostRecentError: structuredError("slide_decode_failed", { assetId: "asset-a" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const incidents = harness.incidents();
|
||||||
|
assert.equal(incidents.length, 1, "a persisting error is reported once, not per heartbeat");
|
||||||
|
assert.equal(incidents[0].event, "display.slide_decode_failed");
|
||||||
|
assert.equal(incidents[0].severity, "warning");
|
||||||
|
assert.equal(
|
||||||
|
incidents[0].condition_key,
|
||||||
|
errorConditionKey("lobby-display", { kind: "slide_decode_failed", assetId: "asset-a" }),
|
||||||
|
);
|
||||||
|
assert.equal(JSON.parse(incidents[0].context_json).assetId, "asset-a");
|
||||||
|
assert.equal(harness.outbox().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a resolved display error recovers exactly once", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
harness.service.heartbeat(
|
||||||
|
heartbeatPayload({
|
||||||
|
mostRecentError: structuredError("asset_fetch_failed", { assetId: "asset-a" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(harness.outbox().length, 1);
|
||||||
|
|
||||||
|
for (let beat = 1; beat <= 3; beat += 1) {
|
||||||
|
harness.clock.set(BASE_TIME + beat * 12_000);
|
||||||
|
harness.service.heartbeat(heartbeatPayload({ mostRecentError: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const incidents = harness.incidents();
|
||||||
|
assert.equal(incidents.length, 1);
|
||||||
|
assert.equal(incidents[0].state, "recovered");
|
||||||
|
assert.equal(harness.outbox().length, 2, "one failure and one recovery");
|
||||||
|
assert.equal(harness.outbox()[1].transition, "recovery");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an error moving to another asset recovers the old condition and opens the new one", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
harness.service.heartbeat(
|
||||||
|
heartbeatPayload({
|
||||||
|
mostRecentError: structuredError("asset_fetch_failed", { assetId: "asset-a" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
harness.clock.set(BASE_TIME + 12_000);
|
||||||
|
harness.service.heartbeat(
|
||||||
|
heartbeatPayload({
|
||||||
|
mostRecentError: structuredError("asset_fetch_failed", { assetId: "asset-b" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const byKey = Object.fromEntries(
|
||||||
|
harness.incidents().map((incident) => [incident.condition_key, incident]),
|
||||||
|
);
|
||||||
|
const keyA = errorConditionKey("lobby-display", {
|
||||||
|
kind: "asset_fetch_failed",
|
||||||
|
assetId: "asset-a",
|
||||||
|
});
|
||||||
|
const keyB = errorConditionKey("lobby-display", {
|
||||||
|
kind: "asset_fetch_failed",
|
||||||
|
assetId: "asset-b",
|
||||||
|
});
|
||||||
|
assert.equal(byKey[keyA].state, "recovered", "the resolved asset condition closes");
|
||||||
|
assert.equal(byKey[keyB].state, "open", "the new asset condition opens");
|
||||||
|
assert.equal(harness.outbox().length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only validated error codes become incidents", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
// Arbitrary browser text is telemetry, never a Home Assistant notification.
|
||||||
|
harness.service.heartbeat(
|
||||||
|
heartbeatPayload({ mostRecentError: "TypeError: undefined is not a function" }),
|
||||||
|
);
|
||||||
|
assert.equal(harness.incidents().length, 0);
|
||||||
|
assert.equal(
|
||||||
|
harness.service.list()[0].mostRecentError,
|
||||||
|
"TypeError: undefined is not a function",
|
||||||
|
"the raw telemetry is still visible to operators",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A structured payload with an unrecognised code is equally inert.
|
||||||
|
harness.clock.set(BASE_TIME + 12_000);
|
||||||
|
harness.service.heartbeat(
|
||||||
|
heartbeatPayload({ mostRecentError: structuredError("something_invented") }),
|
||||||
|
);
|
||||||
|
assert.equal(harness.incidents().length, 0);
|
||||||
|
assert.equal(harness.outbox().length, 0);
|
||||||
|
assert.equal(harness.service.list()[0].mostRecentError.kind, "something_invented");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a manifest that cannot render locally is critical", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
harness.service.heartbeat(
|
||||||
|
heartbeatPayload({ mostRecentError: structuredError("manifest_invalid") }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(harness.incidents()[0].severity, "critical");
|
||||||
|
assert.equal(DISPLAY_ERROR_SEVERITY.manifest_invalid, "critical");
|
||||||
|
assert.equal(DISPLAY_ERROR_SEVERITY.offline_capability_unavailable, "warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("display error incidents never disturb the display offline condition", (t) => {
|
||||||
|
const harness = createHarness();
|
||||||
|
t.after(() => harness.database.close());
|
||||||
|
|
||||||
|
harness.service.heartbeat(heartbeatPayload());
|
||||||
|
// The display goes offline, then returns while reporting a slide problem.
|
||||||
|
harness.clock.runTo(BASE_TIME + THRESHOLD);
|
||||||
|
assert.equal(harness.incidents().length, 1);
|
||||||
|
assert.equal(harness.incidents()[0].event, "display.offline");
|
||||||
|
|
||||||
|
harness.clock.set(BASE_TIME + 60_000);
|
||||||
|
harness.service.heartbeat(
|
||||||
|
heartbeatPayload({
|
||||||
|
mostRecentError: structuredError("slide_decode_failed", { assetId: "asset-a" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const offline = harness
|
||||||
|
.incidents()
|
||||||
|
.find((incident) => incident.condition_key === "display:lobby-display");
|
||||||
|
assert.equal(offline.state, "recovered", "returning still recovers the offline condition once");
|
||||||
|
const decode = harness
|
||||||
|
.incidents()
|
||||||
|
.find((incident) => incident.event === "display.slide_decode_failed");
|
||||||
|
assert.equal(decode.state, "open");
|
||||||
|
assert.equal(
|
||||||
|
harness.incidents().filter((incident) => incident.event === "display.offline").length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user