Audit, fix, and verify the Phase 3B playback engine under Node 22 with a full backend suite, project lint/build checks, Docker builds, Compose validation, and isolated runtime playback verification. Two concrete correctness bugs were found and fixed, each with a regression test that fails without the fix: reconcileRotating() evaluated boundaries against a stale entry. After fastForwardStable() skipped whole cycles it could move the stable cursor, but the loop kept using the pre-skip entry, so that entry's schedule end or expiration cut the successor's slide short and shifted every later absolute deadline. Re-evaluate the loop when the cursor moves. resumeHeldEntry() could reconstruct a timeline whose slide_started_at was in the future. A remainder captured under a longer duration was replayed verbatim after the effective duration shrank, publishing a snapshot whose current slide had not started yet and whose remaining time exceeded its own effective duration. Clamp the remainder to the effective duration. Two Phase 3A tests built playback_state rows by raw SQL that the service can never hold: an activation queued behind a playlist with no playable entry, and a static hold that still carried a live absolute timeline. Both now drive the authoritative service, keeping their original assertions about deletion protection and revision bookkeeping. Record the static-target deletion refusal as a known interim integration point rather than final behavior; playback already tolerates a deleted static asset, so Phase 3B does not depend on it. Co-Authored-By: Claude Opus 5 <[email protected]>
196 lines
9.2 KiB
Markdown
196 lines
9.2 KiB
Markdown
# Announcements v2
|
||
|
||
Announcements is a small Express, React, Socket.IO, MQTT, SQLite, and
|
||
S3-compatible signage application. Version 2 is being built in phases and is
|
||
intended to run beside the existing production version until cutover.
|
||
|
||
## Development
|
||
|
||
The repository is a pnpm workspace with one lockfile. It requires Node.js
|
||
22.13 or newer and pnpm 10.29.3.
|
||
|
||
```sh
|
||
cp .env.example .env
|
||
pnpm install --frozen-lockfile
|
||
pnpm check
|
||
docker compose up --build
|
||
```
|
||
|
||
Only the Nginx web service publishes a host port (5175 by default). The API and
|
||
SeaweedFS S3 endpoint remain private on Compose networks. Replace the example
|
||
S3 credentials before starting the stack.
|
||
|
||
## Library assets
|
||
|
||
`POST /api/assets` accepts multipart uploads in the repeated `file` or `files`
|
||
field. An optional `display_name` (or `displayName`) is accepted for a
|
||
single-file upload. Supported decoded formats are still JPEG, PNG, and WebP;
|
||
extensions and multipart content types are not trusted. Animated, multipage,
|
||
malformed, unsupported, oversized, and excessive-pixel inputs are rejected.
|
||
|
||
Default limits are:
|
||
|
||
- 25 MiB per file (`UPLOAD_MAX_FILE_BYTES`)
|
||
- 20 files per request (`UPLOAD_MAX_FILES`)
|
||
- 40 million decoded pixels per image (`IMAGE_MAX_INPUT_PIXELS`)
|
||
|
||
Nginx accepts up to 512 MiB per request, enough for the default aggregate plus
|
||
multipart overhead. If the backend limits are raised, update
|
||
`client_max_body_size` in `client/nginx.conf` as well. Proxied API requests use
|
||
a ten-minute inactivity timeout so a valid large batch can finish processing.
|
||
|
||
Original bytes are archived unchanged. Content identity is the SHA-256 of
|
||
those bytes, so uploading the same content returns the existing asset even
|
||
when the filename differs. Display-name collisions with different content
|
||
return `409 ASSET_NAME_CONFLICT`. Multi-file uploads validate every new image
|
||
locally before persisting any of them. If a later storage operation fails,
|
||
already completed items remain valid and retrying the request is idempotent.
|
||
|
||
Sharp produces two metadata-free, auto-oriented WebP renditions:
|
||
|
||
- display: inside-fit at most 1920×1080, quality 82
|
||
- thumbnail: inside-fit at most 480×270, quality 72
|
||
|
||
Neither rendition crops or upscales. Transparency is flattened against black.
|
||
Presentation effects such as blur, shadows, darkening, and framing are not
|
||
baked into stored media.
|
||
|
||
## Asset and media API
|
||
|
||
- `GET /api/assets`
|
||
- `POST /api/assets`
|
||
- `GET /api/assets/:assetId`
|
||
- `PATCH /api/assets/:assetId`
|
||
- `GET /api/assets/:assetId/references`
|
||
- `DELETE /api/assets/:assetId`
|
||
- `GET /api/assets/:assetId/original`
|
||
- `GET /media/assets/:assetId/display/:sha256.webp`
|
||
- `GET /media/assets/:assetId/thumbnail/:sha256.webp`
|
||
|
||
PATCH accepts only display name and expiration. A null expiration is permanent.
|
||
Manual deletion refuses playlist, current playback, and static references. It
|
||
marks an asset as deleting before idempotently removing its three objects and
|
||
removes SQLite metadata only after storage succeeds. A failed processing row
|
||
can be inspected, safely deleted, and uploaded again; no automatic retry loop
|
||
is run.
|
||
|
||
Media URLs are derived from SQLite metadata and never accept object keys. The
|
||
display and thumbnail routes stream private S3 objects through the API with a
|
||
one-year immutable cache policy. Original downloads use private, no-store
|
||
caching.
|
||
|
||
## Playlists
|
||
|
||
Playlist and entry metadata is authoritative in SQLite. The API surface is:
|
||
|
||
- `GET /api/playlists`
|
||
- `POST /api/playlists`
|
||
- `GET /api/playlists/:playlistId`
|
||
- `PATCH /api/playlists/:playlistId`
|
||
- `DELETE /api/playlists/:playlistId`
|
||
- `POST /api/playlists/:playlistId/entries`
|
||
- `PATCH /api/playlists/:playlistId/entries/:entryId`
|
||
- `DELETE /api/playlists/:playlistId/entries/:entryId`
|
||
- `PUT /api/playlists/:playlistId/entries/order`
|
||
- `POST /api/playlists/:playlistId/activate`
|
||
|
||
Entry positions are zero-based and contiguous. Reordering requires the complete
|
||
ordered list as `{ "entryIds": [...] }`. An asset can occur only once in a
|
||
playlist, but the same asset can be used independently by any number of other
|
||
playlists. Only ready, unexpired assets can be newly added.
|
||
|
||
Entry duration overrides may be null (inherit the global setting) or 1,000 to
|
||
3,600,000 milliseconds. Presentation overrides are nullable; darkening is an
|
||
inherit/off/on boolean and blur always comes from the global setting. Schedule
|
||
instants use epoch milliseconds or ISO strings with an explicit `Z` or numeric
|
||
UTC offset, and intervals are evaluated as `[start, end)`.
|
||
|
||
Playlist detail responses include effective duration and presentation,
|
||
server-evaluated eligibility, immutable display/thumbnail URLs, and the
|
||
settings revision and evaluation time used. Playlist revisions increment once
|
||
per semantic content change. Exact no-ops and reads do not increment them.
|
||
|
||
Before a playback-relevant playlist, settings, or asset mutation, the playback
|
||
service catches up using the old metadata. The domain transaction then commits
|
||
and playback synchronously reconciles the new values at the mutation instant.
|
||
This prevents a late timer or restart boundary from being rewritten by a later
|
||
edit. If the post-commit reconciliation unexpectedly fails, the API returns an
|
||
error even though the preceding domain commit may already be durable; clients
|
||
must refetch current state before deciding whether to retry.
|
||
|
||
Activation is explicit. A request is held in the existing pending-activation
|
||
state while a playable normal slide is mid-duration; otherwise the target
|
||
playlist is prepared immediately at its first currently playable entry. The
|
||
output-off flag remains only a visual gate, and static hold is preserved while
|
||
the hidden normal playlist changes. The authoritative playback service consumes
|
||
a pending activation at the current slide boundary and never merges the old and
|
||
new playlists.
|
||
|
||
Socket.IO publishes `playlists:changed` as a refetch invalidation after
|
||
committed playlist changes and after settings or referenced-asset edits that
|
||
affect resolved playlist data. Socket.IO is not a mutation API.
|
||
|
||
## Authoritative playback
|
||
|
||
One serialized service owns persisted global playback, timer reconciliation,
|
||
and every playback control path. Its REST surface is:
|
||
|
||
- `GET /api/playback`
|
||
- `POST /api/playback/pause`
|
||
- `POST /api/playback/resume`
|
||
- `POST /api/playback/next`
|
||
- `POST /api/playback/previous`
|
||
- `POST /api/playback/static`
|
||
- `PUT /api/playback/mode`
|
||
- `POST /api/playback/reload`
|
||
|
||
Visual output precedence is mode off (black), a valid static asset, the current
|
||
normal entry, then black when no playable target exists. Mode off is only a
|
||
visual gate: normal playback continues underneath unless it is independently
|
||
paused or held by static mode. Pause and static both preserve the normal cursor
|
||
and remaining duration. Resume clears both static and paused state together and
|
||
continues the normal entry with its preserved remaining time. Next and previous
|
||
wrap across currently playable entries; they change the held entry without
|
||
unpausing when paused and are no-ops while static.
|
||
|
||
Normal slides use persisted absolute start times and deadlines. Chained
|
||
timeouts reconcile from the prior authoritative deadline, including late
|
||
callbacks and restart catch-up, so delays do not accumulate as timing drift.
|
||
The service also arms exact schedule and asset-expiration boundaries. It skips
|
||
disabled, out-of-schedule, expired, and non-ready entries, and safely returns to
|
||
an eligible entry when one becomes available. Pending playlist activation is
|
||
applied at the current normal slide boundary; an empty target playlist still
|
||
becomes active with black normal output.
|
||
|
||
Socket.IO sends `playback:state` immediately on connection and after meaningful
|
||
committed playback changes. The authoritative snapshot includes server time and
|
||
revisions, mode and transport state, active playlist and timeline data, current
|
||
and static targets, crossfade settings, and a display-cache manifest with
|
||
effective duration, presentation, scheduling, expiration, and immutable media
|
||
metadata. Enabled future-scheduled entries remain in the manifest; disabled
|
||
entries and private S3 details do not. Reads and connections do not increment
|
||
the playback revision.
|
||
|
||
Phase 3B exposes this state and behavior through REST and Socket.IO only. MQTT
|
||
command wiring and the browser display renderer remain later phases.
|
||
|
||
Deleting a library asset that is the current static target is still refused by
|
||
the Phase 2 deletion model. That refusal is a known interim integration point,
|
||
not the intended final behavior: deleting the statically held slide is meant to
|
||
exit static safely, return to normal rotation, and only then delete the asset.
|
||
Phase 3B does not depend on the refusal, so the change belongs to the later
|
||
phase that revisits asset lifecycle.
|
||
|
||
## 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.
|