MediaandClaude Opus 5 993986c25e Complete Phase 3B authoritative playback
Audit, fix, and verify the Phase 3B playback engine under Node 22 with a
full backend suite, project lint/build checks, Docker builds, Compose
validation, and isolated runtime playback verification.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 22:22:03 -05:00

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.

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.

S
Description
Announcements, using python and html for display
Readme
52 MiB
Languages
JavaScript 73%
CSS 15.7%
Dockerfile 6.2%
HTML 5.1%